From 2a575750d511a47d69a5c97c41ec121deff920ef Mon Sep 17 00:00:00 2001 From: Dreven Date: Fri, 25 Sep 2026 20:51:17 +0300 Subject: [PATCH 01/33] 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 02/33] 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 03/33] 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 2729b39d1ce526e852a7532aa8f78961bbfe5ea7 Mon Sep 17 00:00:00 2001 From: Dreven Date: Thu, 1 Oct 2026 00:43:01 +0300 Subject: [PATCH 04/33] Adopt the accepted Direction, Horizon and Debt repairs as three rule versions Direction becomes direction.bands.v2 and carries both accepted repairs in one rule move. Linkage is state-neutral (PV-REV-DIRECTION-CLOSED-TARGET-001): a change request linked to a declared target stays linked when the target closes, which repairs calibration finding 9, where closing A1 and B6 took the linked count from 2 of 6 to 0. The open-target count is still derived, for presentation only. Every active change request is LINKED, UNLINKED or UNRESOLVED (PV-DIRECTION-INCOMPLETE-001): a reference a complete register lacks is a broken reference and unlinked, with DIRECTION_TARGET_REFERENCE_MISSING; a reference nobody could resolve, or a title, body or milestone of the wrong type, is unresolved. Over L + U + R = N the completion set k in L..L+R is mapped through the unchanged predicates: one reachable band is DEGRADED / band / EXACT, several are UNKNOWN. Unreadable linkage evidence therefore no longer fails the whole project; LinkageEvidenceError is gone, and the record names what it could not read in linkage_unresolved. Horizon becomes horizon.bands.v2 (PV-HORIZON-PARTIAL-001). A partial target enumeration that returned a target proves the capability; its counts are observed-subset lower bounds, and only PH3, an open target already beyond the frame, forces a band: DEGRADED / EXTENDED / EXACT. PH0 to PH2 stay UNKNOWN, because Horizon declares no order to pick a representative by. Debt becomes debt.bands.v2 (PV-DEBT-PARTIAL-001). A partial register with a confirmed open item is DEGRADED / PRESENT / EXACT, one without is UNKNOWN, and, as issue #39 asked for Debt, a PARTIAL count is read as a lower bound only with its OBSERVED_SUBSET_COUNT proof. The target marker is a standalone token now (PV-AUDIT-TARGET-MARKER-SYNTAX-001): SubTarget: B1 and PreTarget:B1 link nothing. Counts that contradict each other (L + U + R != N, beyond > future > open) are InadmissibleEvidence, refused before classification and reported by the command line as an input error. The vector format learns two things the new families need: given.derive runs the derivation over stated inventories, so a case about how a change request becomes linked is executed where that happens, and expect.rejected states a refusal (HOR-PARTIAL-15). DIR-CLOSED-01..09, DIR-INCOMPLETE-01..16, HOR-PARTIAL-01..16 and DEBT-PARTIAL-01..16 are executable vectors. Co-Authored-By: Claude Opus 5.5 --- docs/spec/PROVENANCE.md | 6 +- docs/spec/conformance.md | 116 + docs/spec/github-adapter.md | 2 +- docs/spec/observations.md | 16 +- docs/spec/registers.md | 21 +- docs/spec/vectors.md | 37 +- docs/spec/vitals.md | 134 +- examples/vectors/example.json | 43 + schemas/conformance-vector.schema.json | 20 +- src/devostasis/adapters/github.py | 96 +- src/devostasis/cli.py | 7 + src/devostasis/normalize.py | 98 +- src/devostasis/vectors.py | 67 +- src/devostasis/vitals/common.py | 19 + src/devostasis/vitals/debt.py | 115 +- src/devostasis/vitals/direction.py | 190 +- src/devostasis/vitals/horizon.py | 171 +- tests/conformance/test_planning_and_debt.py | 31 +- tests/helpers.py | 6 +- tests/test_bundle_history.py | 5 +- tests/test_demand_display_registers.py | 10 +- tests/test_github_adapter.py | 101 +- tests/test_linkage_markers.py | 93 + tests/test_normalize.py | 9 +- tests/test_vector_runner.py | 51 +- tests/vectors/debt-partial.json | 1269 ++++++++ tests/vectors/direction.json | 3188 +++++++++++++++++++ tests/vectors/horizon-partial.json | 1330 ++++++++ 28 files changed, 7036 insertions(+), 215 deletions(-) create mode 100644 tests/test_linkage_markers.py create mode 100644 tests/vectors/debt-partial.json create mode 100644 tests/vectors/direction.json create mode 100644 tests/vectors/horizon-partial.json diff --git a/docs/spec/PROVENANCE.md b/docs/spec/PROVENANCE.md index fbe8580..74bdd22 100644 --- a/docs/spec/PROVENANCE.md +++ b/docs/spec/PROVENANCE.md @@ -18,6 +18,10 @@ normative here. The process is domain-neutral and documented in the | history store and reports | PV-REPORT-001 | accepted by PV-REV-REPORT-001 with the non-decisions resolved as documented; RPT-4..RPT-7/RPT-9 remain open implementation work | | `PV-BAND-ORDER-001`, `devostasis.band-order.v1` | PV-BAND-ORDER-001, which supersedes the PV-ORDER-001 research item that asked whether an order could be declared at all | accepted, adoption authorized; adopted in 0.1.8 with cases ORDER-01..15, executable as vectors. Ordering is per Vital only. PV-SPEC-001 reviewed the adoption and found the semantics conformant and the case identifiers reassigned; the repair restored ORDER-01..15 to the meanings the contract's `Required conformance cases` section gives them before 0.1.8 was tagged | | `integrity.bands.v1+ci-unit-004` | PV-REV-INTEGRITY-UNKNOWN-001 (issue #13, REPAIR REQUIRED with INT-UNKNOWN-01..06), PV-REV-TEST-003 (a PARTIAL required series is UNKNOWN), PV-REV-TEST-VECTORS-002 (sample_strength and CI_SPARSE_SAMPLE, issue #21) | accepted; adopted in 0.1.9 as one Integrity rule version. PV-INTEGRITY-TOTALITY-001 (accepted by PV-REV-INT-TOTALITY-001, INT-TOTAL-01..14; 01..06 and 08..13 executable, 07 and 14 need a RAW_RUNS path and a GitLab adapter) is part of the same unreleased version: it replaced the unresolved superset this version first derived mechanically for #12 finding 3, which the review of 2026-09-30 found could omit the band it emitted | +| `direction.bands.v2` | PV-REV-DIRECTION-CLOSED-TARGET-001 (calibration finding 9, REPAIR REQUIRED with DIR-CLOSED-01..09), PV-DIRECTION-INCOMPLETE-001 (accepted by PV-REV-DIRECTION-INCOMPLETE-001, DIR-INCOMPLETE-01..16) | accepted; adopted in 0.2.0 as one Direction rule version: state-neutral linkage, LINKED/UNLINKED/UNRESOLVED per change request and the completion-set rule; the open-target count stays derived for presentation only. The acceptance of PV-DIRECTION-INCOMPLETE-001 narrows DIR-CLOSED-04: a positively missing target is unlinked, an unresolvable one unresolved; unreadable linkage evidence no longer fails the project | +| `horizon.bands.v2` | PV-HORIZON-PARTIAL-001 (accepted by PV-REV-HORIZON-PARTIAL-001, HOR-PARTIAL-01..16) | accepted; adopted in 0.2.0: over a partial target enumeration a singleton completion set is `DEGRADED / / EXACT` and anything wider `UNKNOWN`; the counts carry the observed-subset proof of PV-REV-PR-031-003 | +| `debt.bands.v2` | PV-DEBT-PARTIAL-001 (accepted by PV-REV-DEBT-PARTIAL-001, DEBT-PARTIAL-01..16); the Debt part of issue #39 | accepted; adopted in 0.2.0: a confirmed open item under partial acquisition is `DEGRADED / PRESENT / EXACT`, none confirmed is `UNKNOWN`; a PARTIAL count is read only with its subset proof. The Pulse part of #39 waits for the research answer it asks for | +| target marker grammar | PV-AUDIT-TARGET-MARKER-SYNTAX-001 (REPAIR REQUIRED, regressions LINK-MARKER-01..08 known only by category) | repaired in 0.2.0 in the adapter; no Vital rule changes for it, the Direction rule version it lands under moves for the repairs above | | `PV-REV-ACTIVITY-COVERAGE-001` | the judgement on issue #9 | accepted reading 3, runtime unchanged since 0.1.7; ACT-COV-01..05 adopted in 0.1.9 as `activity` vectors | | `clutter.bands.v1`, `PV-CLUTTER-INCOMPLETE-001` | PV-CLUTTER-INCOMPLETE-001 (accepted by PV-REV-CLUTTER-INCOMPLETE-001 with CLU-INCOMPLETE-01..20), PV-ISSUE-026-RECONCILE-001 (the reading of issue #26 under that contract), PV-REV-PR-031-003 (a PARTIAL count proves a floor only with machine-verifiable subset semantics, CLU-PARTIAL-TRUST-01..08) | accepted; adopted in 0.1.9 as one Clutter rule version with the twenty cases executable; the T7 upper-bound path moved under the same rule id; the subset proof (`value_semantics = OBSERVED_SUBSET_COUNT` in a PARTIAL count's coverage) is part of the same rule version, which 0.1.9 introduces and no stored bundle carries yet | | exact vector families T2/R1/R2, R3, R4, T4/T5/T7 | PV-TEST-VECTORS-002/005/004/007, accepted by PV-REV-TEST-VECTORS-002/005/004/007 | transcribed into `tests/vectors/` in 0.1.9 as the accepted expectations; T5 (`PULSE_ISSUE_ONLY_ACTIVITY`) adopted under `pulse.bands.v1` as the accepted vector requires; T7 (`retention_semantics = UNCLASSIFIED` upper bound) adopted under `clutter.bands.v1`, because the Clutter rule had to move for PV-CLUTTER-INCOMPLETE-001 in the same release and the accepted T7 semantics are unchanged | @@ -26,7 +30,7 @@ normative here. The process is domain-neutral and documented in the | `devostasis.demand.v2` | PV-ROLE-001 (minimal consumer demand interface); v1 defined by the implementation, v2 removes the cross-Vital gauge tie-break required by ROLE-01 | default table, UNRESOLVED semantics and aggregate-free projection accepted by PV-ROLE-001; the v2 repair was found conformant by PV-SPEC-001; predictive validity under calibration in PV-CAL-004, whose measurement the owner reassigned to the implementation side | | `devostasis.fleet.v1` | implementation-defined projection submitted for judgement | accepted by PV-REV-FLEET-001 (FLEET-01..10): `aggregate` and `cross_project_order` stay exactly null, no accepted contract defines a cross-project order | | `devostasis.ci-outcomes.github.v1` | implementation choice within PV-CI-NORM-001 | accepted by PV-CAL-003 (timed_out is VERIFY_FAIL, startup_failure is UNKNOWN) | -| `devostasis.targets.v1`, `devostasis.debt.v1`, register link marker | implementation choice under the explicit-register allowance of the V1 taxonomy | accepted by PV-REV-REGISTERS-001 (J1..J6, cases REG-01..08): the literal marker satisfies G7 and bulk editability stays provenance-visible; calibration finding 9 (a delivered target's closure un-links the work that delivered it) was judged separately by PV-REV-DIRECTION-CLOSED-TARGET-001 as REPAIR REQUIRED, a state-neutral Direction linkage with a versioned rule id and cases DIR-CLOSED-01..09, not yet adopted | +| `devostasis.targets.v1`, `devostasis.debt.v1`, register link marker | implementation choice under the explicit-register allowance of the V1 taxonomy | accepted by PV-REV-REGISTERS-001 (J1..J6, cases REG-01..08): the literal marker satisfies G7 and bulk editability stays provenance-visible; calibration finding 9 (a delivered target's closure un-links the work that delivered it) was judged separately by PV-REV-DIRECTION-CLOSED-TARGET-001 as REPAIR REQUIRED and is repaired in 0.2.0 by `direction.bands.v2`; REG-04 ("a marker referencing a closed target is not linked-to-open-target") still holds for the presentation count and no longer decides the band | | `devostasis.render.v4`, `devostasis.canon.v1`, `devostasis.effective-config.v2` | implementation choices within the artifact contract | local | Implementation choices the contracts left open, with their disposition after diff --git a/docs/spec/conformance.md b/docs/spec/conformance.md index 22daf68..1849f81 100644 --- a/docs/spec/conformance.md +++ b/docs/spec/conformance.md @@ -124,6 +124,120 @@ identifier. Cases 13 to 16 are the accepted answer to issue #26. | CLU-PARTIAL-TRUST-08 | complete AVAILABLE/FRESH evidence and the optional UNAVAILABLE path are unchanged | `test_clu_partial_trust_08_complete_and_optional_unavailable_evidence_are_unchanged` | | invariant band under unclassified branches | when the work items alone reach the band the full unclassified count reaches, the band is DEGRADED and EXACT (section 5 of the contract) | `test_clutter_unclassified_branches_that_the_work_items_already_reach_are_an_invariant_band` | +## Direction: state-neutral and incomplete linkage (PV-REV-DIRECTION-CLOSED-TARGET-001, PV-DIRECTION-INCOMPLETE-001) + +Rule `direction.bands.v2`, adopted in 0.2.0 as one Direction rule version for +both repairs. The cases are transcribed from the judgement's and the +contract's required fixtures. A case about how one change request's evidence +becomes LINKED, UNLINKED or UNRESOLVED states the change-request and target +inventories and runs the derivation (`given.derive`); a case about the +completion rule states N, L, U and R. PV-REV-DIRECTION-INCOMPLETE-001 narrowed +DIR-CLOSED-04's "unlinked or unresolved": a positively missing target is +UNLINKED (DIR-CLOSED-04, DIR-INCOMPLETE-07), an unresolvable one UNRESOLVED +(DIR-INCOMPLETE-08, -09). + +| Case | Meaning | Test | +| --- | --- | --- | +| DIR-CLOSED-01 | an active change request with an explicit link to an open target is linked | `vector:DIR-CLOSED-01` | +| DIR-CLOSED-02 | the same change request and target id stay linked when the target moves from open to closed; the linkage share is unchanged | `vector:DIR-CLOSED-02`, `test_change_request_aggregates` | +| DIR-CLOSED-03 | a link to a closed target is linked when the target identity resolves authoritatively | `vector:DIR-CLOSED-03`, `test_a_closed_register_target_is_still_a_resolved_reference` | +| DIR-CLOSED-04 | a reference to a target the complete register positively lacks is unlinked with the missing-reference diagnostic; linkage is never fabricated | `vector:DIR-CLOSED-04` | +| DIR-CLOSED-05 | closing a target cannot by itself worsen the Direction band | `vector:DIR-CLOSED-05` | +| DIR-CLOSED-06 | reopening a target cannot by itself improve the Direction band | `vector:DIR-CLOSED-06` | +| DIR-CLOSED-07 | Horizon's open-target counts still exclude the closed target a change request stays linked to, so the two Vitals may diverge | `vector:DIR-CLOSED-07` | +| DIR-CLOSED-08 | every active change request linked to one closed target is FULLY_LINKED with the single-target diagnostic: traceability, not alignment | `vector:DIR-CLOSED-08` | +| DIR-CLOSED-09 | adopting the repair changes the Direction rule id: the first comparison across it is INCOMPARABLE for Direction alone, while an unaffected Vital still compares | `vector:DIR-CLOSED-09` | + +| Case | Meaning | Test | +| --- | --- | --- | +| DIR-INCOMPLETE-01 | N=5, L=0, U=3, R=2: every completion k in {0,1,2} is SCATTERED | `vector:DIR-INCOMPLETE-01` | +| DIR-INCOMPLETE-02 | N=5, L=3, U=1, R=1: every completion k in {3,4} is MIXED | `vector:DIR-INCOMPLETE-02` | +| DIR-INCOMPLETE-03 | N=4, L=2, U=0, R=2: completions reach MIXED and FULLY_LINKED, so no band | `vector:DIR-INCOMPLETE-03` | +| DIR-INCOMPLETE-04 | N=5, L=1, U=1, R=3: completions reach SCATTERED and MIXED, so no band | `vector:DIR-INCOMPLETE-04` | +| DIR-INCOMPLETE-05 | N=1, L=0, U=0, R=1: completions reach SCATTERED and FULLY_LINKED; never DEGRADED FULLY_LINKED | `vector:DIR-INCOMPLETE-05` | +| DIR-INCOMPLETE-06 | a marker to an authoritatively resolved closed target is LINKED; closing or reopening the target alone does not change the linkage | `vector:DIR-INCOMPLETE-06` | +| DIR-INCOMPLETE-07 | a marker to T that a complete authoritative lookup proves absent is UNLINKED: N=1, L=0, U=1, R=0 is exactly SCATTERED with the missing-reference diagnostic | `vector:DIR-INCOMPLETE-07`, `test_a_reference_the_complete_register_lacks_is_missing_and_one_the_register_could_not_answer_is_unknown` | +| DIR-INCOMPLETE-08 | a marker to T while the target list is PARTIAL and T is not in the observed subset, with no direct lookup, is UNRESOLVED: no band | `vector:DIR-INCOMPLETE-08` | +| DIR-INCOMPLETE-09 | a marker whose target resolution is unavailable, forbidden, unknown, errored or stale is UNRESOLVED, never LINKED by fallback | `vector:DIR-INCOMPLETE-09`, `test_unreadable_linkage_evidence_is_unresolved_not_unlinked_and_not_a_failed_project`, `test_a_milestone_without_a_number_is_unresolved_linkage_not_a_failed_project` | +| DIR-INCOMPLETE-10 | a PARTIAL global target enumeration does not degrade Direction when every referenced target is resolved on its own | `vector:DIR-INCOMPLETE-10` | +| DIR-INCOMPLETE-11 | partial link evidence leaves two change requests unresolved, but N/L/U/R forces SCATTERED for every completion | `vector:DIR-INCOMPLETE-11` | +| DIR-INCOMPLETE-12 | partial link evidence admits MIXED and FULLY_LINKED, so no band | `vector:DIR-INCOMPLETE-12` | +| DIR-INCOMPLETE-13 | linkage capability that is neither positively present nor absent is UNKNOWN with no band | `vector:DIR-INCOMPLETE-13` | +| DIR-INCOMPLETE-14 | an active-change inventory that is PARTIAL leaves N unproven: UNKNOWN with no band | `vector:DIR-INCOMPLETE-14` | +| DIR-INCOMPLETE-15 | reversed and reshuffled enumeration, page boundaries, marker order and target order leave L/U/R, the reachable bands, the status, the band and the diagnostics unchanged | `vector:DIR-INCOMPLETE-15` | +| DIR-INCOMPLETE-16 | several active change requests all linked to one authoritatively resolved closed target, with complete evidence, are exactly FULLY_LINKED: neutral, no concentration threshold | `vector:DIR-INCOMPLETE-16` | +| readable link dominates | a resolved reference links a change request even when another of its linkage fields is unreadable | `test_a_readable_link_is_not_undone_by_an_unreadable_field` | +| contradictory counts | `L + U + R != N` is refused before classification, and the command line reports it as an input error | `vector:HOR-PARTIAL-15` (the Horizon analogue), `test_observations_whose_counts_contradict_each_other_are_an_input_error` | + +## Target marker syntax (PV-AUDIT-TARGET-MARKER-SYNTAX-001) + +The audit's regressions `LINK-MARKER-01..08` reached this repository only as +the categories its handoff names; the tests below cover each category and do +not claim the individual case texts. + +| Category | Meaning | Test | +| --- | --- | --- | +| exact marker | a standalone marker links, at the start of the text, after a newline, a space, a tab or punctuation | `test_an_exact_marker_links_wherever_it_stands_alone` | +| embedded prefix | `NotTarget:`, `SubTarget:`, `PreTarget:` and any marker glued to a preceding word character link nothing | `test_a_marker_embedded_in_a_larger_token_links_nothing`, `test_a_marker_ending_in_a_word_character_needs_a_boundary_after_it_too` | +| custom marker | a configured marker replaces the default and is matched exactly | `test_a_custom_marker_replaces_the_default_and_is_matched_exactly` | +| multiple markers | several markers keep their order and link each target once | `test_several_markers_keep_their_order_and_link_each_target_once` | +| prose without the marker | a lowercase, pluralised or paraphrased marker links nothing | `test_prose_without_the_configured_marker_links_nothing` | +| end to end | under file planning only the standalone marker becomes a reference, and Direction counts only it | `test_embedded_markers_do_not_link_end_to_end` | + +## Horizon: partial enumeration (PV-HORIZON-PARTIAL-001) + +Rule `horizon.bands.v2`, adopted in 0.2.0. The sixteen cases of the accepted +contract, stated as target inventories and derived, so the counts, their +observed-subset proof and the capability a returned target proves are executed +where they are made. HOR-PARTIAL-15 states the contradictory counts directly +and expects their refusal (`expect.rejected`). + +| Case | Meaning | Test | +| --- | --- | --- | +| HOR-PARTIAL-01 | capability present, complete and fresh, zero open targets: exactly UNDECLARED (the positive-zero control) | `vector:HOR-PARTIAL-01` | +| HOR-PARTIAL-02 | complete and fresh, one open target with no future boundary: exactly DECLARED | `vector:HOR-PARTIAL-02` | +| HOR-PARTIAL-03 | complete and fresh, an open target exactly 28 days out and none later: exactly VISIBLE; the frame is unchanged | `vector:HOR-PARTIAL-03` | +| HOR-PARTIAL-04 | complete and fresh, an open target strictly beyond 28 days: exactly EXTENDED | `vector:HOR-PARTIAL-04` | +| HOR-PARTIAL-05 | explicit targets positively unsupported and no planning register configured: exactly UNDECLARED, a descriptive state, not missing evidence | `vector:HOR-PARTIAL-05` | +| HOR-PARTIAL-06 | capability unknown, forbidden, errored, stale, unavailable or an unresolved partial: UNKNOWN, never UNDECLARED by default | `vector:HOR-PARTIAL-06` | +| HOR-PARTIAL-07 | PH0: a partial enumeration returned no open target, so every band is reachable: UNKNOWN, never UNDECLARED | `vector:HOR-PARTIAL-07` | +| HOR-PARTIAL-08 | PH1: returned open targets without a future boundary reach DECLARED, VISIBLE and EXTENDED: UNKNOWN, DECLARED is never a representative | `vector:HOR-PARTIAL-08` | +| HOR-PARTIAL-09 | PH2: a returned open target within or exactly at 28 days reaches VISIBLE and EXTENDED: UNKNOWN, VISIBLE is never a bound | `vector:HOR-PARTIAL-09` | +| HOR-PARTIAL-10 | PH3: a returned open target strictly beyond 28 days forces EXTENDED in every completion: DEGRADED / EXTENDED / EXACT | `vector:HOR-PARTIAL-10` | +| HOR-PARTIAL-11 | a returned closed target beyond 28 days and an open target without a boundary: the closed one forces nothing, PH1, UNKNOWN | `vector:HOR-PARTIAL-11` | +| HOR-PARTIAL-12 | only closed returned targets, one beyond 28 days: PH0, UNKNOWN, never UNDECLARED or EXTENDED | `vector:HOR-PARTIAL-12` | +| HOR-PARTIAL-13 | the same PH3 evidence in reversed order, other page boundaries and an equivalent provider-neutral record shape: identical DEGRADED / EXTENDED / EXACT | `vector:HOR-PARTIAL-13` | +| HOR-PARTIAL-14 | the same PH2 subset under record and page permutations: identical UNKNOWN, pagination order cannot manufacture exactness | `vector:HOR-PARTIAL-14` | +| HOR-PARTIAL-15 | a derived tuple no target population can produce (beyond above future, future above open) fails validation before evaluation, never UNKNOWN or DEGRADED compensation | `vector:HOR-PARTIAL-15` | +| HOR-PARTIAL-16 | PH3 is the direct proof: whatever else the returned subset holds, and whatever the omitted records are, the observed open target beyond 28 days keeps every completion EXTENDED | `vector:HOR-PARTIAL-16` | + +## Debt: partial register (PV-DEBT-PARTIAL-001) + +Rule `debt.bands.v2`, adopted in 0.2.0 together with the observed-subset proof +issue #39 asked Debt to require: a PARTIAL open count is a lower bound only +when its coverage says so. + +| Case | Meaning | Test | +| --- | --- | --- | +| DEBT-PARTIAL-01 | a partial enumeration with no confirmed open item cannot manufacture CLEAR | `vector:DEBT-PARTIAL-01` | +| DEBT-PARTIAL-02 | a returned subset of closed debt items only is still not CLEAR: the closed count is a diagnostic, omitted open items stay possible | `vector:DEBT-PARTIAL-02` | +| DEBT-PARTIAL-03 | one confirmed open item forces PRESENT: DEGRADED / PRESENT / EXACT with the partial receipt retained | `vector:DEBT-PARTIAL-03` | +| DEBT-PARTIAL-04 | thirty-seven confirmed open items remain PRESENT, never ACCUMULATED or any quantitative severity | `vector:DEBT-PARTIAL-04` | +| DEBT-PARTIAL-05 | the size of the omitted tail cannot erase a confirmed open item: two different partial receipts give the same band | `vector:DEBT-PARTIAL-05` | +| DEBT-PARTIAL-06 | provider naming, item order, page order and page boundaries do not change the normalized result | `vector:DEBT-PARTIAL-06` | +| DEBT-PARTIAL-07 | positively absent debt authority stays exactly UNINSTRUMENTED; the repair does not turn positive absence into UNKNOWN | `vector:DEBT-PARTIAL-07` | +| DEBT-PARTIAL-08 | uncertain mapping authority is never inferred from a partial payload that happens to hold an apparent open debt record | `vector:DEBT-PARTIAL-08` | +| DEBT-PARTIAL-09 | a complete, fresh positive zero remains exactly CLEAR | `vector:DEBT-PARTIAL-09` | +| DEBT-PARTIAL-10 | a complete, fresh positive open count remains exactly PRESENT | `vector:DEBT-PARTIAL-10` | +| DEBT-PARTIAL-11 | a partial zero stays UNKNOWN beside one hundred closed items: closed history cannot prove current open emptiness | `vector:DEBT-PARTIAL-11` | +| DEBT-PARTIAL-12 | a forced PRESENT under partial acquisition stays visibly DEGRADED, never AVAILABLE, with the acquisition machine-visible | `vector:DEBT-PARTIAL-12` | +| DEBT-PARTIAL-13 | a stale positive count is not a confirmed current open item: UNKNOWN, as before the repair | `vector:DEBT-PARTIAL-13` | +| DEBT-PARTIAL-14 | one confirmed current open item survives other returned or tail records that stay unresolved under the partial receipt | `vector:DEBT-PARTIAL-14` | +| DEBT-PARTIAL-15 | a versioned project-local quantitative policy does not replace the canonical core under partial positive evidence | `vector:DEBT-PARTIAL-15` | +| DEBT-PARTIAL-16 | a project-local policy cannot turn a partial zero into any canonical conclusion | `vector:DEBT-PARTIAL-16` | +| subset proof | a PARTIAL open count whose coverage is missing, not a subset, an estimate, contradictory or malformed is no lower bound, and Debt is UNKNOWN (issue #39) | `test_a_partial_debt_count_without_the_subset_proof_is_not_a_lower_bound` | +| PD1 in the tests | a confirmed open item under partial acquisition is DEGRADED / PRESENT / EXACT, a stale one proves nothing | `test_partial_register_with_observed_items_is_degraded_present`, `test_a_stale_partial_register_proves_nothing_about_now` | + ## Activity coverage (PV-REV-ACTIVITY-COVERAGE-001) Reading 3 of issue #9, accepted: the interval stays the full canonical gap and @@ -357,6 +471,8 @@ accepted case. | corpus | every vector in the corpus runs, and every declared kind is exercised | `test_conformance_vector`, `test_every_kind_the_format_declares_is_exercised_by_the_corpus` | | variants | one case over several evidence shapes passes only when every shape does, and a failure names the shape | `test_variants_hold_every_evidence_shape_to_the_one_expectation` | | ci kind | provider-native outcomes are normalized before Integrity sees them; a provider without a normalization is rejected | `test_the_ci_kind_normalizes_provider_native_outcomes_before_integrity_sees_them` | +| derive | a vital case over inventories runs the derivation first, under the two settings it reads, and no others | `test_derive_runs_the_derivation_before_the_vital_is_evaluated`, `test_derive_names_only_the_settings_the_derivation_reads` | +| rejected | a case expecting refused evidence passes only when the evaluation raises with that code, and the refusal is its whole expectation | `test_a_rejected_case_passes_only_when_the_evidence_is_refused_with_that_code`, `test_rejected_is_the_whole_expectation_and_names_a_code` | | activity kind | the interval and the coverage notes of the activity member are checked | `test_the_activity_kind_checks_the_interval_and_the_coverage_it_discloses` | Cases not yet implemented as tests (T3, T6, T8, T9, R5..R53, ART-05, diff --git a/docs/spec/github-adapter.md b/docs/spec/github-adapter.md index 96f8aab..51ddb1a 100644 --- a/docs/spec/github-adapter.md +++ b/docs/spec/github-adapter.md @@ -63,7 +63,7 @@ place (research audits `PV-AUDIT-GITHUB-*-PAYLOAD-001`). Concretely: | --- | --- | | repository metadata | an object with an integer `id`, a non-empty `default_branch`, boolean `has_issues` and `archived`, and `pushed_at` null or a timestamp; a body that does not establish them fails the project (`CollectionError`) rather than guessing `main` or reading a string's truthiness | | commits | every row an object with a non-empty `sha`, an object `commit`, object or null `committer` and `author`, and at least one readable date; a commit without a date is not skipped | -| change requests | every row an object with an integer `number`, readable `created_at` and `updated_at`, null or readable `merged_at` and `closed_at`, boolean `draft`, object or null `user`; the window predicate itself validates `updated_at`; linkage-bearing `title`, `body` and `milestone` keep their own fail-closed rule (`LinkageEvidenceError`) | +| change requests | every row an object with an integer `number`, readable `created_at` and `updated_at`, null or readable `merged_at` and `closed_at`, boolean `draft`, object or null `user`; the window predicate itself validates `updated_at`; linkage-bearing `title`, `body` and `milestone` of the wrong type are not an absent link and not a failed project: the record keeps what could be read and names the rest in `linkage_unresolved`, and Direction counts the change request unresolved unless a readable reference already links it (`PV-DIRECTION-INCOMPLETE-001`) | | issues | every row an object with an integer `number`, readable timestamps, a list of objects with string `name` as `labels`, object or null `user` | | branches | every row an object with a non-empty `name`, an object `commit` with a non-empty `sha`, boolean `protected`; a head detail that cannot be read leaves that head unresolved (`PARTIAL / BRANCH_HEADS_UNRESOLVED`), never stale or fresh | | milestones | every row an object with an integer `number`, null or readable `due_on`, integer or null `open_issues` and `closed_issues` | diff --git a/docs/spec/observations.md b/docs/spec/observations.md index 216b955..f9bc4d6 100644 --- a/docs/spec/observations.md +++ b/docs/spec/observations.md @@ -65,7 +65,7 @@ Inventories (emitted by adapters): | --- | --- | --- | | `forge.repository.metadata` | record | id, default branch, visibility, `has_issues`, archived, pushed_at | | `git.default_branch.commits_28d` | series | `{sha, committed_at, title}` for the 28-day window; coverage `complete` | -| `forge.change_requests.inventory` | series | all open change requests plus those updated in 28 days; coverage `open_complete`, `window_complete` | +| `forge.change_requests.inventory` | series | all open change requests plus those updated in 28 days; coverage `open_complete`, `window_complete`. Each record carries `target_refs`, the explicit target references with the state their resolution established (`OPEN` or `CLOSED` when the declared target resolves, `MISSING` when a complete register positively lacks the id, `UNKNOWN` when nothing could resolve it), and `linkage_unresolved`, the linkage fields that could not be read (`body:dict`, `milestone:NO_NUMBER`) | | `forge.issues.inventory` | series | all open issues plus those updated in 28 days; `UNAVAILABLE` when issues are disabled | | `git.nondefault_branches.inventory` | series | `{name, head_sha, head_committed_at, protected}`; coverage `complete`, `heads_resolved` | | `git.nondefault_branches.retention_semantics` | enum | whether the purpose of the non-default branches is known: `UNCLASSIFIED` makes a stale count an upper bound on residue ([vitals.md](vitals.md#clutter), case T7). Optional; the GitHub adapter does not emit it in this version | @@ -83,8 +83,8 @@ relevant coverage flag): | `forge.change_requests.open_count`, `.merged_count_28d`, `.updated_count_28d`, `.stale_open_count_14d`, `.oldest_open_age_days` (when open > 0), `.median_time_to_merge_seconds_28d` (when merged > 0; exact rational record `{"numerator": n, "denominator": d}` in seconds), `.median_time_to_merge_hours_28d` (whole-hour projection of the former, presentation only) | change requests | | `forge.issues.open_count`, `.stale_open_count_30d`, `.updated_count_28d` | issues | | `git.nondefault_branches.stale_count_30d` | branches (heads older than 30 days) | -| `planning.explicit_targets.capability` (`SUPPORTED`, `SUPPORTED_UNUSED`, `UNSUPPORTED`), `.open_count`, `.open_with_future_boundary_count`, `.open_beyond_28d_count`, `.nearest_future_boundary_days` | targets and configuration | -| `planning.linkage.active_change_requests_count_28d`, `.active_change_requests_linked_to_open_target_count_28d`, `.links_per_target_28d` | change requests with milestone linkage | +| `planning.explicit_targets.capability` (`SUPPORTED`, `SUPPORTED_UNUSED`, `UNSUPPORTED`), `.open_count`, `.open_with_future_boundary_count`, `.open_beyond_28d_count`, `.nearest_future_boundary_days` | targets and configuration; a fresh partial enumeration that returned a target proves `SUPPORTED` (`coverage.basis = RETURNED_TARGET_RECORD`), and its counts are observed-subset lower bounds | +| `planning.linkage.active_change_requests_count_28d`, `.active_change_requests_linked_count_28d`, `.active_change_requests_unlinked_count_28d`, `.active_change_requests_unresolved_count_28d`, `.unresolved_change_requests_28d` (record: change request number to its unresolved reasons), `.links_per_target_28d` (resolved references per target, open or closed), `.unknown_target_reference_count_28d`, `.missing_target_reference_count_28d`, `.active_change_requests_linked_to_open_target_count_28d` (presentation only since `direction.bands.v2`) | change requests with their target references | | `debt.registry.capability` (`CONFIGURED`, `UNCONFIGURED`), `debt.mapping`, `debt.items.open_count`, `.open_stale_count_30d`, `.closed_count_28d` | issues filtered by the configured label mapping | A count aggregate derived from an incomplete inventory is `PARTIAL`, and its @@ -94,15 +94,19 @@ enumeration did return, which the records it did not return can only increase. That declaration is what lets a Vital read a `PARTIAL` value as a lower bound (`PV-REV-PR-031-003`); `PARTIAL` alone never does. Every count above carries it; `oldest_open_age_days`, the two medians and -`links_per_target_28d` never do, because a maximum, a median or a record of a +`links_per_target_28d` and `unresolved_change_requests_28d` never do, because +a maximum, a median or a record of a subset is not a lower bound of anything a Vital classifies. A complete aggregate's coverage is unchanged, so this declaration moves no bundle identity built over complete evidence. Definitions: "stale" means open and not updated for the stated number of days; "active" change requests are those updated within the 28-day frame; "linked" -means the change request carries an explicit reference to an open planning -target; merge durations are computed exactly from the timestamps (fractional +means the change request carries an explicit reference to a declared planning +target that resolves, open or closed (state-neutral since +`direction.bands.v2`), "unlinked" that it carries none or only references a +complete register positively lacks, and "unresolved" that its linkage +evidence could not be read or a reference could not be resolved; merge durations are computed exactly from the timestamps (fractional seconds of an RFC 3339 timestamp are kept as exact decimal fractions, never rounded through binary floats), the "median" over an odd sample is the middle duration and over an even sample the exact arithmetic mean of the two middle diff --git a/docs/spec/registers.md b/docs/spec/registers.md index cf08e65..40bf96e 100644 --- a/docs/spec/registers.md +++ b/docs/spec/registers.md @@ -29,10 +29,23 @@ Configuration: `"planning": {"source": "file", "path": "devostasis/targets.json" `DECLARED`; with a date within 28 days `VISIBLE`; beyond, `EXTENDED`. A change request is **linked** to a target when its title or body contains -the marker followed by the target id, for example a line `Target: T-3`. Several -markers link to several targets. A reference to an id that is not in the -register counts as unlinked and is reported in -`planning.linkage.unknown_target_reference_count_28d`. +the marker followed by the target id, for example a line `Target: T-3`. The +marker is a token of its own, matched exactly, case included: it starts the +text or follows a character that cannot continue a word, so `SubTarget: T-3`, +`NotTarget: T-3` and `PreTarget:T-3` link nothing +(`PV-AUDIT-TARGET-MARKER-SYNTAX-001`); a marker that ends in a letter or digit +also needs a boundary after it. Several markers link to several targets, in +order, each once. + +Linkage is state-neutral: a closed target is still the target the work was +declared against, so a change request that references it stays linked +(`PV-REV-DIRECTION-CLOSED-TARGET-001`). Keep closed targets in the file for as +long as recent work references them. A reference to an id the register does +not contain is a broken reference: the change request counts as unlinked, +`planning.linkage.missing_target_reference_count_28d` reports it, and +Direction carries `DIRECTION_TARGET_REFERENCE_MISSING`. When the register +itself cannot be read, nothing about the id is known, so the change request is +unresolved rather than unlinked (`PV-DIRECTION-INCOMPLETE-001`). A missing file is `UNAVAILABLE / REGISTER_NOT_FOUND` and makes Horizon and Direction `UNKNOWN` (the mechanism is configured but absent). An invalid file diff --git a/docs/spec/vectors.md b/docs/spec/vectors.md index 262ef46..309bcae 100644 --- a/docs/spec/vectors.md +++ b/docs/spec/vectors.md @@ -11,7 +11,8 @@ cases with no test behind them (debt D-1, target B1). Since 0.1.9 the accepted exact vectors of the research process are in the corpus (seven of those 70, with sixty-three still to come), beside the accepted families transcribed from their contracts (`ORDER`, `INT-UNKNOWN`, `INT-TOTAL`, `CLU-INCOMPLETE`, -`ACT-COV`). Authored against this format a case arrives executable instead of +`ACT-COV`, and since 0.2.0 `DIR-CLOSED`, `DIR-INCOMPLETE`, `HOR-PARTIAL`, +`DEBT-PARTIAL`). Authored against this format a case arrives executable instead of needing translation. ## A vector file @@ -81,6 +82,40 @@ list of at least two `{title, observations}` entries, all held to the one failure names the shape by its `title`. `variants` replaces `observations`; stating both is an error. +A case about how inventories become aggregates states the inventories and +`given.derive`: the provider-neutral derivation (`normalize.derive`) runs +over the envelopes before the Vital is evaluated, under the two project +settings it reads, `planning_source` (`none`, `milestones` or `file`) and +`debt_mapping` (the explicit mapping object, or `null`). Aggregates the +envelopes already state are kept, as the derivation keeps them for a saved +observation set. The Direction, Horizon and Debt families of 0.2.0 use it, +so a change request linked to a closed target or a partial target list is +executed where the linkage and the counts are made, not over counts +somebody derived by hand. + +```json +{"case": "EXAMPLE-DERIVE-01", "kind": "vital", + "title": "a returned open target beyond the frame forces EXTENDED over a partial enumeration", + "given": {"vital": "horizon", "observed_at": "2026-09-07T12:00:00Z", + "derive": {"planning_source": "milestones", "debt_mapping": null}, + "observations": [ + {"observation_id": "planning.explicit_targets.inventory", "value_type": "series", + "status": "PARTIAL", "reason_code": "PAGINATION_CAPPED", + "coverage": {"complete": false, "source": "milestones"}, + "value": [{"target_id": "1", "state": "OPEN", "due_at": "2026-12-01T00:00:00Z"}]}]}, + "expect": {"band": "EXTENDED", "evaluation_status": "DEGRADED", "band_semantics": "EXACT", + "possible_bands": ["EXTENDED"], "diagnostics": ["HORIZON_PARTIAL_BAND_INVARIANT"]}} +``` + +A case whose evidence must be refused states `{"rejected": ""}` as its +whole `expect`: the evaluation must raise `InadmissibleEvidence` with that +code (or one it is a prefix of). The totality contracts of 0.2.0 give +contradictory normalized input that outcome, before classification and +never as an `UNKNOWN` or `DEGRADED` result (HOR-PARTIAL-15; Direction's +`L + U + R != N`). A refusal combined with any other expectation, or naming +no code, is an error; a case that expects a refusal and gets a result fails +and says which result it got. + ### `kind: "delta"` Compares two snapshots given as partial Vital rows and checks transition diff --git a/docs/spec/vitals.md b/docs/spec/vitals.md index 2290c6f..844fd55 100644 --- a/docs/spec/vitals.md +++ b/docs/spec/vitals.md @@ -27,7 +27,10 @@ Every Vital emits: `vital_id`, `vital_version`, `rule_id`, `band`, `evaluation_status` (`AVAILABLE`, `DEGRADED`, `UNKNOWN`), `band_semantics` (`EXACT`, `CONSERVATIVE_LOWER_BOUND`, `CONSERVATIVE_UPPER_BOUND`, -`NON_AUTHORITATIVE_CONSERVATIVE_SUPERSET`), `possible_bands` when degraded, +`NON_AUTHORITATIVE_CONSERVATIVE_SUPERSET`), `possible_bands` when degraded +(a `DEGRADED` band that every completion of the missing evidence reaches is +`EXACT`, and carries the singleton or no `possible_bands` as its contract +says), `inputs` with status and freshness, `derived` metrics actually used, `shared_signal_groups`, `dependency_group_ids`, `diagnostics` and a deterministic `explanation`. An `UNKNOWN` Vital has `band = null`. @@ -37,12 +40,12 @@ taxonomy and the policy constants. The seven rule ids of this version: | Vital | `rule_id` | Since | | --- | --- | --- | -| Horizon | `horizon.bands.v1` | 0.1.0 | +| Horizon | `horizon.bands.v2` | 0.2.0 (partial target enumeration, PV-HORIZON-PARTIAL-001) | | Clutter | `clutter.bands.v1` | 0.1.9 (the incomplete-evidence contract and its subset proof) | -| Direction | `direction.bands.v1.1` | 0.1.0 | +| Direction | `direction.bands.v2` | 0.2.0 (state-neutral and incomplete linkage, PV-REV-DIRECTION-CLOSED-TARGET-001 and PV-DIRECTION-INCOMPLETE-001) | | Flow | `flow.bands.v1` | 0.1.2 (the calibration repairs) | | Integrity | `integrity.bands.v1+ci-unit-004` | 0.1.9 (the newest-UNKNOWN, partial-series, sample-strength and totality judgements) | -| Debt | `debt.bands.v1.1` | 0.1.0 | +| Debt | `debt.bands.v2` | 0.2.0 (partial register, PV-DEBT-PARTIAL-001) | | Pulse | `pulse.bands.v1` | 0.1.2 (the calibration repairs) | A rule version change never rewrites a @@ -320,6 +323,35 @@ Inputs: `planning.explicit_targets.capability`, `.open_count`, `EXTENDED` is not better than `VISIBLE`. Creating empty milestones improves Horizon; that is why Horizon exposes counts and never contributes to a score. +A boundary exactly 28 days out is not beyond the frame. Only open targets +count: a closed target with a distant boundary proves nothing here, even while +Direction still counts the work linked to it (DIR-CLOSED-07). + +**Partial enumeration** (PV-HORIZON-PARTIAL-001, rule `horizon.bands.v2`). A +fresh target enumeration that returned at least one target proves the +capability `SUPPORTED`, because more targets cannot remove it; one that +returned none proves neither support nor its absence, and Horizon is +`UNKNOWN`. Over a partial enumeration the three counts are observed-subset +lower bounds (`value_semantics = OBSERVED_SUBSET_COUNT`, the proof +`PV-REV-PR-031-003` requires), and the band table is evaluated over every +admissible completion of the missing targets. Horizon declares no order, so a +representative of several reachable bands is never chosen: + +| Observed subset | Reachable bands | Result | +| --- | --- | --- | +| PH0: no open target | all four | `UNKNOWN`, `HORIZON_PARTIAL_AMBIGUOUS` | +| PH1: open targets, none with a future boundary | DECLARED, VISIBLE, EXTENDED | `UNKNOWN` | +| PH2: a future boundary, none beyond 28 days | VISIBLE, EXTENDED | `UNKNOWN` | +| PH3: an open target beyond 28 days | EXTENDED | `DEGRADED` / `EXTENDED` / `EXACT`, `possible_bands = [EXTENDED]`, `HORIZON_PARTIAL_BAND_INVARIANT` | + +`EXACT` there means the band is the same in every completion of this +snapshot, which the observed target beyond the frame guarantees (existential +monotonicity, not an ordering); it never means the enumeration was complete. +The reachable set is kept in `derived.reachable_bands` as explanation, never +as a band claim, and each bounded count says `LOWER_BOUND` in `derived`. A +partial zero is never `UNDECLARED`. Counts no target population can produce +(`beyond > future` or `future > open`) are refused before classification with +`HORIZON_COUNTS_INCONSISTENT` (HOR-PARTIAL-15). Cases HOR-PARTIAL-01..16. Groups: `PLANNING_TARGETS`; dependency `HORIZON_DIRECTION_PLANNING`. @@ -329,17 +361,64 @@ Groups: `PLANNING_TARGETS`; dependency `HORIZON_DIRECTION_PLANNING`. must be explicit and auditable (milestone on the change request, or the register marker); keyword, branch-name or model heuristics are forbidden. -Inputs: `planning.linkage.active_change_requests_count_28d`, -`.active_change_requests_linked_to_open_target_count_28d`, -`planning.explicit_targets.capability`, `.links_per_target_28d`. - -| Band | Rule | +Inputs: `planning.linkage.active_change_requests_count_28d` (N), +`.active_change_requests_linked_count_28d` (L), +`.active_change_requests_unlinked_count_28d` (U), +`.active_change_requests_unresolved_count_28d` (R), +`planning.explicit_targets.capability`, `.links_per_target_28d`, +`.missing_target_reference_count_28d`, `.unresolved_change_requests_28d`. + +Every active change request is exactly one of: + +- **LINKED**: it carries an explicit reference to a declared target that + resolves, open or closed; +- **UNLINKED**: it carries no reference, or only references that a complete + register positively lacks (a broken reference, diagnosed + `DIRECTION_TARGET_REFERENCE_MISSING`); +- **UNRESOLVED**: its linkage evidence could not be read (a title, body or + milestone of the wrong type), or a reference could not be resolved + (register unreadable; resolution unavailable, forbidden, errored or stale). + Absence from a partial target list is never deletion proof. + +| Band | Rule, for k linked of N | | --- | --- | -| NO_ACTIVE_CHANGE | `active = 0` | -| UNDECLARED | `active > 0` and planning capability positively absent (`UNSUPPORTED`, `SUPPORTED_UNUSED`) | -| SCATTERED | `unlinked > linked` | -| MIXED | `unlinked > 0` and `unlinked <= linked` | -| FULLY_LINKED | `unlinked = 0` | +| NO_ACTIVE_CHANGE | `N = 0` | +| UNDECLARED | `N > 0` and planning capability positively absent (`UNSUPPORTED`, `SUPPORTED_UNUSED`) | +| SCATTERED | `2k < N`, the V1 `unlinked > linked` | +| MIXED | `k < N` and `2k >= N`, the V1 `0 < unlinked <= linked` | +| FULLY_LINKED | `k = N` | + +**State-neutral linkage** (PV-REV-DIRECTION-CLOSED-TARGET-001, rule +`direction.bands.v2`). A change request linked to a declared target stays +linked when the target closes: closing the target the work delivered must not +un-trace the work, and counting only open targets rewarded never closing them +(calibration finding 9). Horizon still counts open targets only, so the two +Vitals may diverge. The count of links to targets that are open now is still +derived, `active_change_requests_linked_to_open_target_count_28d`, for +presentation; it is never the classifier's input. + +**Incomplete linkage** (PV-DIRECTION-INCOMPLETE-001). With N complete and +fresh, the capability positively present and `L + U + R = N`, every unresolved +change request may complete either way, so k ranges over `L..L+R` and each k +is mapped through the table above: + +- `R = 0`: the exact band, `AVAILABLE`; +- `R > 0` and every k gives the same band: `DEGRADED` / band / `EXACT`, + `possible_bands` the singleton, `DIRECTION_INCOMPLETE_BAND_INVARIANT`, and + the unresolved change requests named in `derived.unresolved_change_requests`. + `FULLY_LINKED` can never be the only reachable band while `R > 0`; +- `R > 0` and the completions cross a boundary: `UNKNOWN` with no band, + `DIRECTION_INCOMPLETE_AMBIGUOUS`, the reachable set in + `derived.reachable_bands` as explanation only. Direction declares no order to + choose a representative by. + +A partial global target list does not degrade Direction when every target the +active change requests reference is resolved on its own, as a milestone +embedded in the change request is (DIR-INCOMPLETE-10). An active population +that is itself partial, or a capability neither positively present nor +absent, is `UNKNOWN`. Counts with `L + U + R != N` are refused before +classification with `DIRECTION_LINKAGE_COUNTS_INCONSISTENT`. Cases +DIR-CLOSED-01..09 and DIR-INCOMPLETE-01..16. `FULLY_LINKED` is neutral exact traceability. It is never rendered as ALIGNED, ON_TRACK or HEALTHY. Mass-linking every change to one target yields @@ -368,9 +447,30 @@ Inputs: `debt.registry.capability`, `debt.mapping`, `debt.items.open_count`, No universal quantitative thresholds exist for Debt: the retired `ACCUMULATED` band is never emitted. Configured debt evidence that is -forbidden, unknown, errored, stale or partial yields `UNKNOWN`, never `CLEAR`; -a partial register with at least one observed item yields `DEGRADED PRESENT`. -A change of `mapping_version` makes history `INCOMPARABLE`. +forbidden, unknown, errored or stale yields `UNKNOWN`, never `CLEAR`. + +**Partial register** (PV-DEBT-PARTIAL-001, rule `debt.bands.v2`). A `PARTIAL` +open count is read only as an observed-subset lower bound, which its coverage +must declare (`value_semantics = OBSERVED_SUBSET_COUNT`, issue #39), and only +under a configured mapping that is itself observed: + +- at least one confirmed open item: `DEGRADED` / `PRESENT` / `EXACT`, no + `possible_bands`, because every completion of the missing items still holds + that item. `EXACT` names the band, never the count or a severity: + `derived.open_count_semantics` is `LOWER_BOUND`, and `confirmed_open_count` + and the partial receipt (`derived.partial_enumeration`) stay visible, with + `DEBT_PARTIAL_ENUMERATION`, `DEBT_CONFIRMED_OPEN_LOWER_BOUND:` and + `DEBT_BAND_FORCED_PRESENT_BY_CONFIRMED_OPEN`; +- none confirmed: `UNKNOWN` with no band. The completions reach `CLEAR` and + `PRESENT`, Debt declares no order between them, and the observed zero is + absence of proof, not proof of absence; the zero and the receipt stay + visible. + +A partial count without the subset proof is `UNKNOWN` +(`PARTIAL_COUNT_NOT_A_LOWER_BOUND`). Stale and recently closed counts are +diagnostics and never change a result; a project-local quantitative policy +carried in the mapping never replaces the canonical bands. A change of +`mapping_version` makes history `INCOMPARABLE`. Cases DEBT-PARTIAL-01..16. Groups: `EXPLICIT_DEBT_REGISTER`, `FORGE_INVENTORY`; dependency `DEBT_CLUTTER_MAINTENANCE`. diff --git a/examples/vectors/example.json b/examples/vectors/example.json index f592fc5..f9dbc8d 100644 --- a/examples/vectors/example.json +++ b/examples/vectors/example.json @@ -111,6 +111,49 @@ ] } }, + { + "case": "EXAMPLE-DERIVE-01", + "title": "a returned open target beyond the frame forces EXTENDED over a partial enumeration", + "kind": "vital", + "given": { + "vital": "horizon", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED", + "coverage": { + "complete": false, + "source": "milestones" + }, + "value": [ + { + "target_id": "1", + "state": "OPEN", + "due_at": "2026-12-01T00:00:00Z" + } + ] + } + ] + }, + "expect": { + "band": "EXTENDED", + "evaluation_status": "DEGRADED", + "band_semantics": "EXACT", + "possible_bands": [ + "EXTENDED" + ], + "diagnostics": [ + "HORIZON_PARTIAL_BAND_INVARIANT" + ] + } + }, { "case": "EXAMPLE-DELTA-01", "title": "A Vital with a declared order and exact evidence on both sides reports a direction", diff --git a/schemas/conformance-vector.schema.json b/schemas/conformance-vector.schema.json index 01e2355..bb40990 100644 --- a/schemas/conformance-vector.schema.json +++ b/schemas/conformance-vector.schema.json @@ -42,6 +42,16 @@ "vital": { "enum": ["horizon", "clutter", "direction", "flow", "integrity", "debt", "pulse"] }, "observed_at": { "type": "string" }, "subject": { "type": "object" }, + "derive": { + "type": "object", + "description": "run the provider-neutral derivation over the stated inventories before evaluating, under these two project settings", + "required": ["planning_source"], + "additionalProperties": false, + "properties": { + "planning_source": { "enum": ["none", "milestones", "file"] }, + "debt_mapping": { "type": ["object", "null"], "description": "the explicit debt mapping (labels or a register file), or null when none is configured" } + } + }, "observations": { "type": "array", "minItems": 1, @@ -246,9 +256,12 @@ "$defs": { "vital_expect": { "title": "What one Vital evaluation must yield", - "description": "The same shape for a vital case and for the Integrity evaluation a ci case may chain into.", + "description": "The same shape for a vital case and for the Integrity evaluation a ci case may chain into: a result to check, or the refusal a case about contradictory evidence expects.", "type": "object", - "required": ["band", "evaluation_status"], + "oneOf": [ + { "required": ["band", "evaluation_status"], "not": { "required": ["rejected"] } }, + { "required": ["rejected"], "maxProperties": 1 } + ], "additionalProperties": false, "properties": { "band": { "type": ["string", "null"] }, @@ -262,7 +275,8 @@ "diagnostics_absent": { "type": "array", "items": { "type": "string" } }, "explanation_contains": { "type": "array", "items": { "type": "string" } }, "shared_signal_groups": { "type": "array", "items": { "type": "string" } }, - "dependency_group_ids": { "type": "array", "items": { "type": "string" } } + "dependency_group_ids": { "type": "array", "items": { "type": "string" } }, + "rejected": { "type": "string", "minLength": 1, "description": "the evidence must be refused before classification with this code (InadmissibleEvidence); the whole expectation of such a case" } } }, "delta_expect": { diff --git a/src/devostasis/adapters/github.py b/src/devostasis/adapters/github.py index 54dcb95..7a998b9 100644 --- a/src/devostasis/adapters/github.py +++ b/src/devostasis/adapters/github.py @@ -71,18 +71,6 @@ class CollectionError(Exception): """The subject could not be identified; no observation set can be produced.""" -class LinkageEvidenceError(CollectionError): - """A change request or issue carries linkage evidence that cannot be read. - - Title, body and milestone are where a target link lives. Provider payload of - the wrong type there is not an absent link: reading it as one would report a - project as unlinked on evidence nobody could parse, which is the silent - absence the register and Direction contracts both forbid. It is a - ``CollectionError`` so the project fails explicitly, keeping its reason, - while every other project in the fleet is still observed. - """ - - @dataclass class ApiFailure(Exception): status_code: int @@ -554,27 +542,43 @@ def _register_title(where: str, value: Any) -> str: return _title(value) -def _linkage_text(where: str, item: dict[str, Any], fields: tuple[str, ...]) -> str: - """Join the free-text fields a target marker can live in, or refuse to guess.""" - parts = [] +def _linkage_text(item: dict[str, Any], fields: tuple[str, ...]) -> tuple[str, list[str]]: + """The readable free-text fields a target marker can live in, and the ones that cannot be read. + + Payload of the wrong type in a title or body is not an absent link: read as + one it would report the change request unlinked on evidence nobody could + parse. It is not a reason to fail the project either. Since + ``PV-DIRECTION-INCOMPLETE-001`` the change request keeps what could be read + and names what could not, and Direction treats its linkage as unresolved + unless a readable reference already links it. + """ + parts: list[str] = [] + unreadable: list[str] = [] for field in fields: value = item.get(field) if value is None or value == "": continue if not isinstance(value, str): - raise LinkageEvidenceError(f"{where}: {field} is {type(value).__name__}, not text, so a target marker cannot be read from it") + unreadable.append(f"{field}:{type(value).__name__}") + continue parts.append(value) - return "\n".join(parts) + return "\n".join(parts), unreadable + +def _milestone_ref(milestone: Any) -> tuple[dict[str, str] | None, str | None]: + """A milestone is a target link: ``(reference, None)``, ``(None, None)`` when absent, ``(None, reason)`` when unreadable. -def _milestone_ref(where: str, milestone: Any) -> dict[str, str] | None: - """A milestone is a target link. Absent is a fact; unreadable is not.""" + The provider embeds the milestone it links, state included, so the + reference is resolved by the change request's own payload: a closed + milestone is still the milestone the work was declared against + (``PV-REV-DIRECTION-CLOSED-TARGET-001``). + """ if not milestone: - return None - if not isinstance(milestone, dict) or milestone.get("number") is None: - raise LinkageEvidenceError(f"{where}: milestone {milestone!r} carries no number, so the target it links to cannot be named") + return None, None + if not isinstance(milestone, dict) or milestone.get("number") is None or isinstance(milestone.get("number"), bool): + return None, "milestone:NO_NUMBER" state = "CLOSED" if str(milestone.get("state") or "").lower() == "closed" else "OPEN" - return {"target_id": str(milestone["number"]), "state": state} + return {"target_id": str(milestone["number"]), "state": state}, None def _normalize_date(value: Any) -> str | None: @@ -593,10 +597,20 @@ def _normalize_date(value: Any) -> str | None: def marker_target_ids(text: str | None, marker: str) -> list[str]: - """Explicit target references: every `` `` occurrence, in order, without duplicates.""" + """Explicit target references: every standalone `` `` occurrence, in order, without duplicates. + + The configured marker is a token of its own, never text inside a larger one + (``PV-AUDIT-TARGET-MARKER-SYNTAX-001``): it starts the text or follows a + character that cannot continue a word, and a marker that ends in a word + character is not followed by another. ``SubTarget: B1``, ``NotTarget: B1`` + and ``PreTarget:B1`` therefore link nothing under ``Target:``, while + ``Target: B1`` at the start of a line, after a space or inside + ``(Target: B1)`` does. The marker is matched exactly, case included. + """ if not text or not marker: return [] - pattern = re.compile(re.escape(marker) + r"[ \t]*([A-Za-z0-9][A-Za-z0-9._/-]*)") + tail = r"(?!\w)" if (marker[-1].isalnum() or marker[-1] == "_") else "" + pattern = re.compile(r"(? dict[str, Any]: raise _payload_failure(path, f"commit {sha} carries no committer or author date") return {"sha": sha, "committed_at": committed, "title": _title(info.get("message"))} - def _target_refs(self, pull: dict[str, Any], project: ResolvedProject, target_states: dict[str, str] | None) -> list[dict[str, str]]: + def _target_refs(self, pull: dict[str, Any], project: ResolvedProject, target_states: dict[str, str] | None) -> tuple[list[dict[str, str]], list[str]]: + """The explicit target references of a change request and what of its linkage evidence could not be read. + + Each reference carries the state its resolution established: + ``OPEN`` or ``CLOSED`` when the declared target resolves (state-neutral + linkage, ``PV-REV-DIRECTION-CLOSED-TARGET-001``), ``MISSING`` when the + complete register positively lacks the id (a broken reference, not + missing evidence), ``UNKNOWN`` when the register could not be read, so + nothing about the id is known (``PV-DIRECTION-INCOMPLETE-001``). + """ source = project.planning["source"] - where = f"change request #{pull.get('number')}" if source == "milestones": - ref = _milestone_ref(where, pull.get("milestone") or None) - return [ref] if ref else [] + ref, problem = _milestone_ref(pull.get("milestone") or None) + return ([ref] if ref else []), ([problem] if problem else []) if source == "file": marker = project.planning.get("link_marker") or "Target:" - text = _linkage_text(where, pull, ("title", "body")) - states = target_states or {} - return [{"target_id": tid, "state": states.get(tid, "UNKNOWN")} for tid in marker_target_ids(text, marker)] - return [] + text, unreadable = _linkage_text(pull, ("title", "body")) + ids = marker_target_ids(text, marker) + if target_states is None: + return [{"target_id": tid, "state": "UNKNOWN"} for tid in ids], unreadable + return [{"target_id": tid, "state": target_states.get(tid, "MISSING")} for tid in ids], unreadable + return [], [] def _collect_change_requests(self, obs: ObservationSet, base: str, project: ResolvedProject, target_states: dict[str, str] | None) -> None: since = timeutil.minus_days(self.now, FLOW["window_days"]) @@ -958,7 +982,7 @@ def _change_request_record(self, path: str, pull: dict[str, Any], project: Resol state = "OPEN" user = pull.get("user") author = None if user is None else _optional_text(path, _object(path, user, f"{where} user").get("login"), f"{where} user.login") - refs = self._target_refs(pull, project, target_states) + refs, linkage_unresolved = self._target_refs(pull, project, target_states) return { "number": number, "id": _optional_integer(path, pull.get("id"), f"{where} id"), @@ -972,6 +996,7 @@ def _change_request_record(self, path: str, pull: dict[str, Any], project: Resol "target_id": refs[0]["target_id"] if refs else None, "target_state": refs[0]["state"] if refs else None, "target_refs": refs, + "linkage_unresolved": sorted(linkage_unresolved), "author": author, "url": _optional_text(path, pull.get("html_url"), f"{where} html_url"), } @@ -989,7 +1014,7 @@ def _issue_record(path: str, issue: dict[str, Any]) -> dict[str, Any]: names.append(_text(path, label.get("name"), f"{where} labels[{position}].name")) user = issue.get("user") author = None if user is None else _optional_text(path, _object(path, user, f"{where} user").get("login"), f"{where} user.login") - milestone = _milestone_ref(where, issue.get("milestone") or None) + milestone, milestone_problem = _milestone_ref(issue.get("milestone") or None) state_text = _optional_text(path, issue.get("state"), f"{where} state") return { "number": number, @@ -1001,6 +1026,7 @@ def _issue_record(path: str, issue: dict[str, Any]) -> dict[str, Any]: "closed_at": _optional_timestamp(path, issue.get("closed_at"), f"{where} closed_at"), "labels": sorted(names), "target_id": milestone["target_id"] if milestone else None, + "linkage_unresolved": [milestone_problem] if milestone_problem else [], "author": author, "url": _optional_text(path, issue.get("html_url"), f"{where} html_url"), } diff --git a/src/devostasis/cli.py b/src/devostasis/cli.py index 14eadea..2888904 100644 --- a/src/devostasis/cli.py +++ b/src/devostasis/cli.py @@ -19,6 +19,7 @@ from .history import FilesystemHistoryStore, HistoryStoreError from .observations import ObservationSet from .runner import FleetSurfaceError, build_from_observations, evaluate, observe, run_all, write_fleet_index +from .vitals.common import InadmissibleEvidence TOKEN_ENVS = ("DEVOSTASIS_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN") @@ -525,5 +526,11 @@ def main(argv: list[str] | None = None) -> int: except InputError as exc: print(f"input error: {exc}", file=sys.stderr) return 2 + except InadmissibleEvidence as exc: + # Counts that cannot all be true are refused before classification + # (PV-DIRECTION-INCOMPLETE-001, PV-HORIZON-PARTIAL-001): the saved + # observation set is invalid input, not a Vital without a band. + print(f"input error: the observations contradict themselves: {exc}", file=sys.stderr) + return 2 except KeyboardInterrupt: return 130 diff --git a/src/devostasis/normalize.py b/src/devostasis/normalize.py index 6995c92..a552e80 100644 --- a/src/devostasis/normalize.py +++ b/src/devostasis/normalize.py @@ -12,7 +12,7 @@ from . import canonical, timeutil from .config import ResolvedProject -from .observations import AVAILABLE, OBSERVED_SUBSET_COUNT, PARTIAL, UNAVAILABLE, VALUE_BEARING, VALUE_SEMANTICS, Observation, ObservationSet +from .observations import AVAILABLE, FRESH, OBSERVED_SUBSET_COUNT, PARTIAL, UNAVAILABLE, VALUE_BEARING, VALUE_SEMANTICS, Observation, ObservationSet from .policy import CLUTTER, FLOW, PLANNING, PULSE INV_REPO = "forge.repository.metadata" @@ -34,6 +34,16 @@ MEDIAN_HOURS = "forge.change_requests.median_time_to_merge_hours_28d" MEDIAN_HOURS_NOTE = "whole-hour projection of median_time_to_merge_seconds_28d for presentation; never a classifier input" +# Direction's classifier reads state-neutral linkage (direction.bands.v2). The +# open-target count it read before is kept for presentation only, as the +# accepted judgement allows (PV-REV-DIRECTION-CLOSED-TARGET-001). +LINKAGE_LINKED = "planning.linkage.active_change_requests_linked_count_28d" +LINKAGE_UNLINKED = "planning.linkage.active_change_requests_unlinked_count_28d" +LINKAGE_UNRESOLVED = "planning.linkage.active_change_requests_unresolved_count_28d" +LINKAGE_UNRESOLVED_ITEMS = "planning.linkage.unresolved_change_requests_28d" +LINKAGE_MISSING_REFS = "planning.linkage.missing_target_reference_count_28d" +LINKED_TO_OPEN_NOTE = "active change requests linked to a target that is open now; presentation only since direction.bands.v2, never a classifier input" + def _derived( obs: ObservationSet, @@ -116,6 +126,40 @@ def target_refs(item: dict[str, Any]) -> list[dict[str, Any]]: return [] +# Direction linkage evidence per active change request (PV-DIRECTION-INCOMPLETE-001, +# section 3). A reference resolves when its declared target does, open or closed +# alike (PV-REV-DIRECTION-CLOSED-TARGET-001); MISSING is a complete register +# positively lacking the id, a broken reference and therefore unlinked; every +# other state, an unreadable title, body or milestone, or a reference of an +# older record shape without a state, is evidence nobody could resolve. +LINKED = "LINKED" +UNLINKED = "UNLINKED" +UNRESOLVED = "UNRESOLVED" +RESOLVED_TARGET_STATES = ("OPEN", "CLOSED") +MISSING_TARGET = "MISSING" + + +def linkage_state(item: dict[str, Any]) -> str: + """LINKED, UNLINKED or UNRESOLVED for one active change request.""" + refs = target_refs(item) + if any(ref.get("state") in RESOLVED_TARGET_STATES for ref in refs): + return LINKED + if item.get("linkage_unresolved") or any(ref.get("state") != MISSING_TARGET for ref in refs): + return UNRESOLVED + return UNLINKED + + +def unresolved_linkage_reasons(item: dict[str, Any]) -> list[str]: + """Why an UNRESOLVED change request is unresolved, in a stable order.""" + reasons = [f"TARGET_UNRESOLVED:{ref['target_id']}:{ref.get('state')}" for ref in target_refs(item) if ref.get("state") not in RESOLVED_TARGET_STATES + (MISSING_TARGET,)] + unreadable = item.get("linkage_unresolved") + if isinstance(unreadable, list): + reasons.extend(f"LINKAGE_UNREADABLE:{reason}" for reason in unreadable) + elif unreadable: + reasons.append(f"LINKAGE_UNREADABLE:{unreadable}") + return sorted(set(reasons)) + + def derive(obs: ObservationSet, project: ResolvedProject) -> ObservationSet: observed_at = timeutil.parse_ts(obs.observed_at) since_pulse = timeutil.minus_days(observed_at, PULSE["window_days"]) @@ -182,26 +226,40 @@ def _updated_in_window(items): def _active(items): return [item for item in items if item.get("updated_at") and timeutil.parse_ts(item["updated_at"]) >= since_planning] - def _open_refs(item): - return [ref for ref in target_refs(item) if ref.get("state") == "OPEN"] + def _in_state(items, state): + return [item for item in _active(items) if linkage_state(item) == state] - def _linked(items): - return [item for item in _active(items) if _open_refs(item)] + def _linked_to_open(items): + return [item for item in _active(items) if any(ref.get("state") == "OPEN" for ref in target_refs(item))] def _links_per_target(items): + # State-neutral: a closed target still carries the links of the work + # declared against it (PV-REV-DIRECTION-CLOSED-TARGET-001, DIR-CLOSED-08). counts: dict[str, int] = {} for item in _active(items): - for ref in _open_refs(item): - counts[str(ref["target_id"])] = counts.get(str(ref["target_id"]), 0) + 1 + for ref in target_refs(item): + if ref.get("state") in RESOLVED_TARGET_STATES: + counts[str(ref["target_id"])] = counts.get(str(ref["target_id"]), 0) + 1 return dict(sorted(counts.items())) - def _unknown_refs(items): - return len([ref for item in _active(items) for ref in target_refs(item) if ref.get("state") == "UNKNOWN"]) + def _refs_in_state(items, state): + return len([ref for item in _active(items) for ref in target_refs(item) if ref.get("state") == state]) + + def _unresolved_items(items): + return {str(item.get("number")): unresolved_linkage_reasons(item) for item in _in_state(items, UNRESOLVED)} _derived(obs, "planning.linkage.active_change_requests_count_28d", "count", crs, lambda items: len(_active(items)), complete=window_ok, subset_count=True) - _derived(obs, "planning.linkage.active_change_requests_linked_to_open_target_count_28d", "count", crs, lambda items: len(_linked(items)), complete=window_ok, subset_count=True) + _derived(obs, LINKAGE_LINKED, "count", crs, lambda items: len(_in_state(items, LINKED)), complete=window_ok, subset_count=True) + _derived(obs, LINKAGE_UNLINKED, "count", crs, lambda items: len(_in_state(items, UNLINKED)), complete=window_ok, subset_count=True) + _derived(obs, LINKAGE_UNRESOLVED, "count", crs, lambda items: len(_in_state(items, UNRESOLVED)), complete=window_ok, subset_count=True) + _derived(obs, LINKAGE_UNRESOLVED_ITEMS, "record", crs, _unresolved_items, complete=window_ok) + _derived( + obs, "planning.linkage.active_change_requests_linked_to_open_target_count_28d", "count", crs, + lambda items: len(_linked_to_open(items)), complete=window_ok, subset_count=True, notes=LINKED_TO_OPEN_NOTE, + ) _derived(obs, "planning.linkage.links_per_target_28d", "record", crs, _links_per_target, complete=window_ok) - _derived(obs, "planning.linkage.unknown_target_reference_count_28d", "count", crs, _unknown_refs, complete=window_ok, subset_count=True) + _derived(obs, "planning.linkage.unknown_target_reference_count_28d", "count", crs, lambda items: _refs_in_state(items, "UNKNOWN"), complete=window_ok, subset_count=True) + _derived(obs, LINKAGE_MISSING_REFS, "count", crs, lambda items: _refs_in_state(items, MISSING_TARGET), complete=window_ok, subset_count=True) issues = obs.get(INV_ISSUES) if issues is not None: @@ -315,13 +373,17 @@ def _derive_planning(obs: ObservationSet, project: ResolvedProject, observed_at) if targets is None: obs.add(Observation(observation_id="planning.explicit_targets.capability", status="UNKNOWN", value_type="enum", reason_code="TARGETS_NOT_COLLECTED", **base)) return - if not targets.good: + # A fresh incomplete enumeration that returned a target proves support: more + # records can only add targets (PV-HORIZON-PARTIAL-001, section 3). One that + # returned none proves neither support nor its absence. + proven_subset = targets.status == PARTIAL and targets.freshness == FRESH and isinstance(targets.value, list) and bool(targets.value) + if not targets.good and not proven_subset: obs.add( Observation( observation_id="planning.explicit_targets.capability", status=targets.status if targets.status not in VALUE_BEARING else PARTIAL, value_type="enum", - value="SUPPORTED" if targets.status == PARTIAL else None, + value="SUPPORTED" if (targets.status == PARTIAL and targets.value) else None, reason_code=targets.reason_code or "TARGETS_INCOMPLETE", provider=targets.provider, collected_at=targets.collected_at, source_ref=f"derived:{INV_TARGETS}", adapter_version=targets.adapter_version, freshness=targets.freshness, @@ -335,14 +397,18 @@ def _derive_planning(obs: ObservationSet, project: ResolvedProject, observed_at) observation_id="planning.explicit_targets.capability", status=AVAILABLE, value_type="enum", value=capability, provider=targets.provider, collected_at=targets.collected_at, source_ref=f"derived:{INV_TARGETS}", adapter_version=targets.adapter_version, freshness=targets.freshness, evidence_ref={"derived_from": [INV_TARGETS]}, + coverage=None if targets.good else {"complete": True, "basis": "RETURNED_TARGET_RECORD", "source_coverage": targets.coverage}, + notes=None if targets.good else "support is proved by a returned target record; the target enumeration itself is incomplete", ) ) open_items = [i for i in items if i.get("state") == "OPEN"] future = [i for i in open_items if i.get("due_at") and timeutil.parse_ts(i["due_at"]) > observed_at] beyond = [i for i in future if timeutil.parse_ts(i["due_at"]) > horizon_edge] - _derived(obs, "planning.explicit_targets.open_count", "count", targets, lambda _: len(open_items)) - _derived(obs, "planning.explicit_targets.open_with_future_boundary_count", "count", targets, lambda _: len(future)) - _derived(obs, "planning.explicit_targets.open_beyond_28d_count", "count", targets, lambda _: len(beyond)) + # Counts over the returned targets; over an incomplete enumeration each is an + # observed-subset lower bound and says so (PV-REV-PR-031-003, issue #39). + _derived(obs, "planning.explicit_targets.open_count", "count", targets, lambda _: len(open_items), subset_count=True) + _derived(obs, "planning.explicit_targets.open_with_future_boundary_count", "count", targets, lambda _: len(future), subset_count=True) + _derived(obs, "planning.explicit_targets.open_beyond_28d_count", "count", targets, lambda _: len(beyond), subset_count=True) if future: nearest = min(int((timeutil.parse_ts(i["due_at"]) - observed_at).total_seconds() // 86400) for i in future) _derived(obs, "planning.explicit_targets.nearest_future_boundary_days", "duration", targets, lambda _: nearest) diff --git a/src/devostasis/vectors.py b/src/devostasis/vectors.py index 645dec5..fa09e92 100644 --- a/src/devostasis/vectors.py +++ b/src/devostasis/vectors.py @@ -21,7 +21,13 @@ * **vital** evaluates one Vital over an observation set built from raw observation envelopes (``RAW_OBSERVATION_CONTRACT_V0``), so a vector states - evidence exactly as a collector would emit it. + evidence exactly as a collector would emit it. With ``derive`` the + provider-neutral derivation runs first, so a case about how inventories + become aggregates (a change request linked to a closed target, a partial + target enumeration) is executed at that boundary rather than over counts + somebody pre-derived. ``expect.rejected`` states that the evidence must be + refused before classification (``InadmissibleEvidence``), the outcome the + totality contracts give contradictory normalized input. * **delta** compares two snapshots given as partial Vital rows and checks the transition classes and reason codes. An accepted case often names more than one pair — "``GRIDLOCKED -> CONGESTED -> MOVING`` follows WORSENED/IMPROVED @@ -70,8 +76,10 @@ from .adapters import github_ci from .contracts import CORE_VITAL_IDS, VECTOR_SCHEMA from .delta import BASELINE, COMPARABLE, HISTORY_GAP, INCOMPARABLE, compare +from .normalize import derive as derive_aggregates from .observations import AVAILABLE, PARTIAL, STATUSES, Observation, ObservationSet from .vitals import EVALUATORS +from .vitals.common import InadmissibleEvidence KINDS = ("vital", "delta", "ci", "activity") COMPARISON_STATUSES = (BASELINE, COMPARABLE, HISTORY_GAP, INCOMPARABLE) @@ -84,8 +92,12 @@ # vector, so the kind validators require it rather than this set. REQUIRED_VECTOR_KEYS = {"case", "title", "kind", "given"} -VITAL_GIVEN_KEYS = {"vital", "observations", "observed_at", "subject", "variants"} +VITAL_GIVEN_KEYS = {"vital", "observations", "observed_at", "subject", "variants", "derive"} VITAL_VARIANT_KEYS = {"title", "observations"} +# The configuration the derivation reads: where planning targets come from and +# the explicit debt mapping, the two project settings normalize.derive consults. +DERIVE_KEYS = {"planning_source", "debt_mapping"} +PLANNING_SOURCES = ("none", "milestones", "file") VITAL_EXPECT_KEYS = { "band", "evaluation_status", @@ -99,6 +111,7 @@ "explanation_contains", "shared_signal_groups", "dependency_group_ids", + "rejected", } REQUIRED_VITAL_EXPECT_KEYS = {"band", "evaluation_status"} @@ -231,14 +244,33 @@ def _require_statement(where: str, holder: dict[str, Any], keys: tuple[str, ...] def _validate_vital_expect(where: str, expect: Any) -> None: if expect is None: raise VectorError(f"{where}: missing keys ['expect']") + if isinstance(expect, dict) and "rejected" in expect: + # A refused evaluation has no band, status or metrics to check: the + # rejection code is the whole expectation. + if set(expect) != {"rejected"}: + raise VectorError(f"{where} expect: rejected is the whole expectation of a refused evaluation, not combined with {sorted(set(expect) - {'rejected'})}") + if not isinstance(expect["rejected"], str) or not expect["rejected"]: + raise VectorError(f"{where} expect: rejected must name the rejection code (an empty prefix matches everything)") + return _require_keys(f"{where} expect", expect, VITAL_EXPECT_KEYS, REQUIRED_VITAL_EXPECT_KEYS) _require_statement(f"{where} expect", expect, ("derived",) + CODE_LIST_KEYS) +def _validate_derive(where: str, derive: Any) -> None: + _require_keys(f"{where} given.derive", derive, DERIVE_KEYS, {"planning_source"}) + if derive["planning_source"] not in PLANNING_SOURCES: + raise VectorError(f"{where} given.derive: planning_source {derive['planning_source']!r} is not one of {list(PLANNING_SOURCES)}") + mapping = derive.get("debt_mapping") + if mapping is not None and not isinstance(mapping, dict): + raise VectorError(f"{where} given.derive: debt_mapping is an object or null") + + def _validate_vital(where: str, given: dict[str, Any], expect: Any) -> None: _require_keys(f"{where} given", given, VITAL_GIVEN_KEYS, {"vital"}) if given["vital"] not in EVALUATORS: raise VectorError(f"{where} given: unknown vital {given['vital']!r}") + if "derive" in given: + _validate_derive(where, given["derive"]) if "variants" in given: _require_variants(where, given, {"observations"}, VITAL_VARIANT_KEYS, {"observations"}) for index, variant in enumerate(given["variants"]): @@ -500,16 +532,40 @@ def _vital_shapes(given: dict[str, Any]) -> list[tuple[str, list[dict[str, Any]] return [("", given["observations"])] +@dataclass(frozen=True) +class _DeriveScope: + """The two project settings the derivation reads, stated by a vector instead of a configuration file.""" + + planning_source: str + debt_mapping: dict[str, Any] | None + + def _run_vital(vector: Vector) -> list[str]: """One case, every evidence shape it names. A case passes only when all of them do.""" failures: list[str] = [] + derive = vector.given.get("derive") for where, observations in _vital_shapes(vector.given): obs = _observation_set(vector.given, vector.case, observations) - result = EVALUATORS[vector.given["vital"]](obs).to_dict() - _check_vital_result(result, vector.expect, failures, where) + if derive is not None: + derive_aggregates(obs, _DeriveScope(derive["planning_source"], derive.get("debt_mapping"))) + _evaluate_and_check(EVALUATORS[vector.given["vital"]], obs, vector.expect, failures, where) return failures +def _evaluate_and_check(evaluator: Any, obs: ObservationSet, expect: dict[str, Any], failures: list[str], where: str) -> None: + """Evaluate one Vital and check the result, or check that the evidence is refused when the case says it must be.""" + if "rejected" not in expect: + _check_vital_result(evaluator(obs).to_dict(), expect, failures, where) + return + try: + result = evaluator(obs).to_dict() + except InadmissibleEvidence as exc: + if not _matches(expect["rejected"], [exc.code]): + failures.append(f"{where}rejected: expected {expect['rejected']!r}, the evidence was refused with {exc.code!r}") + return + failures.append(f"{where}rejected: expected the evidence to be refused with {expect['rejected']!r}, it evaluated to {result['band']} / {result['evaluation_status']}") + + def _delta_row(row: dict[str, Any]) -> dict[str, Any]: """A partial Vital row completed with the defaults a vector may leave out.""" band = row.get("band") @@ -639,8 +695,7 @@ def _run_ci(vector: Vector) -> list[str]: **common, ) ) - result = EVALUATORS["integrity"](obs).to_dict() - _check_vital_result(result, vector.expect["integrity"], failures, f"{where}integrity.") + _evaluate_and_check(EVALUATORS["integrity"], obs, vector.expect["integrity"], failures, f"{where}integrity.") return failures diff --git a/src/devostasis/vitals/common.py b/src/devostasis/vitals/common.py index 93aa249..c2b0b14 100644 --- a/src/devostasis/vitals/common.py +++ b/src/devostasis/vitals/common.py @@ -19,6 +19,25 @@ SEM_SUPERSET = "NON_AUTHORITATIVE_CONSERVATIVE_SUPERSET" +class InadmissibleEvidence(ValueError): + """Normalized inputs that contradict each other, so no admissible completion exists. + + The totality contracts adopted in 0.2.0 name this case separately from + missing evidence: counts that cannot all be true at once (``L + U + R != N`` + for Direction, a beyond-28-day count above the future-boundary count for + Horizon) are invalid normalized input and fail validation *before* + classification, never ``UNKNOWN`` or ``DEGRADED`` compensation + (PV-DIRECTION-INCOMPLETE-001 section 7, PV-HORIZON-PARTIAL-001 case + HOR-PARTIAL-15). The derivation never produces such a set; a saved or + hand-written one that does is refused as input. ``code`` is the stable + reason a caller or a vector can match. + """ + + def __init__(self, code: str, detail: str) -> None: + super().__init__(f"{code}: {detail}") + self.code = code + + @dataclass class VitalResult: vital_id: str diff --git a/src/devostasis/vitals/debt.py b/src/devostasis/vitals/debt.py index 28054ea..cd9c249 100644 --- a/src/devostasis/vitals/debt.py +++ b/src/devostasis/vitals/debt.py @@ -4,16 +4,31 @@ exists only through an explicit, versioned mapping (for example issue labels) and without an accepted calibrated policy its bands are PRESENT, CLEAR and UNINSTRUMENTED plus raw diagnostics (PV-VITALS-V1-002). + +Rule ``debt.bands.v2`` adopts **PV-DEBT-PARTIAL-001** (accepted by +PV-REV-DEBT-PARTIAL-001, DEBT-PARTIAL-01..16). A configured register whose +enumeration is PARTIAL is an observed subset: its open count is a lower bound, +admitted only with the observed-subset proof of PV-REV-PR-031-003 (issue #39). +With at least one confirmed open item every completion of the missing items +is still PRESENT, so the result is ``DEGRADED / PRESENT / EXACT``: the band is +exact, the acquisition is not, and neither the count nor any severity is +claimed. With none confirmed, completions reach both CLEAR and PRESENT, and +Debt declares no order between them, so the result is ``UNKNOWN`` with the +partial receipt kept visible. A partial zero is never CLEAR, and PARTIAL +evidence never establishes UNINSTRUMENTED, which needs positive absence of +configured debt authority. """ from __future__ import annotations -from ..observations import FRESH, PARTIAL, ObservationSet +from typing import Any + +from ..observations import FRESH, PARTIAL, ObservationSet, subset_count_problem from .common import ( EVAL_AVAILABLE, EVAL_DEGRADED, + EVAL_UNKNOWN, SEM_EXACT, - SEM_LOWER, VitalResult, as_int, input_meta, @@ -22,7 +37,8 @@ VITAL_ID = "debt" VITAL_VERSION = "PV-VITALS-V1-002/debt" -RULE_ID = "debt.bands.v1.1" +RULE_ID = "debt.bands.v2" +PARTIAL_RULE = "PV-DEBT-PARTIAL-001" BANDS = ["UNINSTRUMENTED", "CLEAR", "PRESENT"] CAPABILITY = "debt.registry.capability" @@ -38,8 +54,12 @@ CAP_CONFIGURED = "CONFIGURED" CAP_UNCONFIGURED = "UNCONFIGURED" +PARTIAL_ENUMERATION = "DEBT_PARTIAL_ENUMERATION" +LOWER_BOUND = "DEBT_CONFIRMED_OPEN_LOWER_BOUND" +FORCED_PRESENT = "DEBT_BAND_FORCED_PRESENT_BY_CONFIRMED_OPEN" + -def _result(obs: ObservationSet, band: str, status: str, semantics: str, possible, derived: dict, explanation: str, diagnostics: list[str]) -> VitalResult: +def _result(obs: ObservationSet, band: str | None, status: str, semantics: str | None, derived: dict, explanation: str, diagnostics: list[str]) -> VitalResult: return VitalResult( vital_id=VITAL_ID, vital_version=VITAL_VERSION, @@ -47,7 +67,7 @@ def _result(obs: ObservationSet, band: str, status: str, semantics: str, possibl band=band, evaluation_status=status, band_semantics=semantics, - possible_bands=possible, + possible_bands=None, inputs=input_meta(obs, IDS), derived=derived, shared_signal_groups=SHARED, @@ -57,8 +77,25 @@ def _result(obs: ObservationSet, band: str, status: str, semantics: str, possibl ) +def _mapping_version(mapping: Any) -> str: + return mapping.get("mapping_version") if isinstance(mapping, dict) else "n/a" + + +def _partial_receipt(obs: ObservationSet, item: Any) -> dict[str, Any]: + """What the incomplete enumeration was: its reason and the inventory it was counted over.""" + sources = (item.evidence_ref or {}).get("derived_from") if isinstance(item.evidence_ref, dict) else None + source = obs.get(sources[0]) if isinstance(sources, list) and sources else None + return { + "status": item.status, + "reason_code": item.reason_code, + "source": source.observation_id if source is not None else None, + "source_reason_code": source.reason_code if source is not None else None, + } + + def evaluate(obs: ObservationSet) -> VitalResult: if not obs.is_good(CAPABILITY): + # DEBT-PARTIAL-08: uncertain authority is never inferred from records. return unknown_result( VITAL_ID, VITAL_VERSION, RULE_ID, obs, IDS, [f"MISSING_REQUIRED:{CAPABILITY}:{obs.status_of(CAPABILITY)}/{obs.freshness_of(CAPABILITY)}"], @@ -67,8 +104,9 @@ def evaluate(obs: ObservationSet) -> VitalResult: capability = obs.value_of(CAPABILITY) mapping = obs.value_of(MAPPING) if obs.is_good(MAPPING) else None if capability == CAP_UNCONFIGURED: + # DEBT-PARTIAL-07: positive absence of configured debt authority. return _result( - obs, "UNINSTRUMENTED", EVAL_AVAILABLE, SEM_EXACT, None, + obs, "UNINSTRUMENTED", EVAL_AVAILABLE, SEM_EXACT, {"capability": capability, "mapping": None}, "No debt register or category mapping is configured; debt is not instrumented.", [], @@ -81,23 +119,58 @@ def evaluate(obs: ObservationSet) -> VitalResult: open_count = as_int(obs.value_of(OPEN)) band = "PRESENT" if open_count > 0 else "CLEAR" derived = dict(derived_base, open_count=open_count) - explanation = f"{open_count} open registered debt items under mapping version {mapping.get('mapping_version') if isinstance(mapping, dict) else 'n/a'}." - return _result(obs, band, EVAL_AVAILABLE, SEM_EXACT, None, derived, explanation, []) + explanation = f"{open_count} open registered debt items under mapping version {_mapping_version(mapping)}." + return _result(obs, band, EVAL_AVAILABLE, SEM_EXACT, derived, explanation, []) open_obs = obs.get(OPEN) - # A stale or freshness-unknown partial register proves nothing about now - # (vitals.md: configured debt evidence that is stale yields UNKNOWN). - if open_obs is not None and open_obs.status == PARTIAL and open_obs.freshness == FRESH and open_obs.has_value and as_int(open_obs.value) > 0: - open_count = as_int(open_obs.value) - derived = dict(derived_base, open_count=open_count, open_count_semantics="LOWER_BOUND") + if open_obs is None or open_obs.status != PARTIAL or open_obs.freshness != FRESH or not open_obs.has_value: + # DEBT-PARTIAL-13: stale, unavailable, forbidden, unknown or errored + # configured evidence stays outside the partial repair. + return unknown_result( + VITAL_ID, VITAL_VERSION, RULE_ID, obs, IDS, + [f"MISSING_REQUIRED:{OPEN}:{obs.status_of(OPEN)}/{obs.freshness_of(OPEN)}"], + SHARED, GROUPS, + explanation="Debt is configured but its register could not be observed completely; CLEAR is never assumed.", + ) + problem = subset_count_problem(open_obs) + if problem is not None: + # Issue #39: status PARTIAL alone proves nothing about the value. + return unknown_result( + VITAL_ID, VITAL_VERSION, RULE_ID, obs, IDS, + [f"MISSING_REQUIRED:{OPEN}:{PARTIAL}/{open_obs.freshness}", f"PARTIAL_COUNT_NOT_A_LOWER_BOUND:{OPEN}:{problem}"], + SHARED, GROUPS, + explanation="Debt is configured, but the partial open count does not prove it is a count over returned register items; no bound is read from it.", + ) + if mapping is None: + # A PD1 result must name the authority it counted under (section 5). + return unknown_result( + VITAL_ID, VITAL_VERSION, RULE_ID, obs, IDS, + [f"MISSING_REQUIRED:{MAPPING}:{obs.status_of(MAPPING)}/{obs.freshness_of(MAPPING)}"], + SHARED, GROUPS, + explanation="Debt is configured and partially enumerated, but the mapping the items were counted under is not observed; no band is claimed.", + ) + + confirmed = as_int(open_obs.value) + derived = dict( + derived_base, + open_count=confirmed, + open_count_semantics="LOWER_BOUND", + confirmed_open_count=confirmed, + partial_enumeration=_partial_receipt(obs, open_obs), + partial_rule=PARTIAL_RULE, + ) + diagnostics = [PARTIAL_ENUMERATION, f"{LOWER_BOUND}:{confirmed}"] + if confirmed > 0: + # PD1: a confirmed current open item survives every completion. return _result( - obs, "PRESENT", EVAL_DEGRADED, SEM_LOWER, ["PRESENT"], derived, - f"At least {open_count} open registered debt items were observed; the register enumeration is incomplete.", - [f"PARTIAL_REGISTER:{OPEN}"], + obs, "PRESENT", EVAL_DEGRADED, SEM_EXACT, derived, + f"At least {confirmed} open registered debt items were observed under mapping version {_mapping_version(mapping)}; " + "the register enumeration is incomplete, so the count is a lower bound and the band is PRESENT in every completion.", + diagnostics + [FORCED_PRESENT], ) - return unknown_result( - VITAL_ID, VITAL_VERSION, RULE_ID, obs, IDS, - [f"MISSING_REQUIRED:{OPEN}:{obs.status_of(OPEN)}/{obs.freshness_of(OPEN)}"], - SHARED, GROUPS, - explanation="Debt is configured but its register could not be observed completely; CLEAR is never assumed.", + # PD0: zero confirmed open items is absence of proof, not proof of absence. + return _result( + obs, None, EVAL_UNKNOWN, None, derived, + "The debt register enumeration is incomplete and returned no open item; the missing items could make Debt CLEAR or PRESENT, and no band is claimed.", + diagnostics, ) diff --git a/src/devostasis/vitals/direction.py b/src/devostasis/vitals/direction.py index f03cd05..ea66a63 100644 --- a/src/devostasis/vitals/direction.py +++ b/src/devostasis/vitals/direction.py @@ -2,37 +2,109 @@ FULLY_LINKED is neutral exact traceability; it is not strategy, prioritization or target quality, and renderers must never alias it to ALIGNED or ON_TRACK. + +Rule ``direction.bands.v2`` adopts two accepted repairs on top of the V1 band +table, neither of which moves a threshold or the 28-day frame: + +* **PV-REV-DIRECTION-CLOSED-TARGET-001** (DIR-CLOSED-01..09): linkage is + state-neutral. An active change request linked to a declared target stays + linked when the target closes, because closing the target the work delivered + must not un-trace the work (calibration finding 9). Horizon still counts open + targets only, so the two Vitals may legitimately diverge. +* **PV-DIRECTION-INCOMPLETE-001** (DIR-INCOMPLETE-01..16): over a complete + active population of N change requests, each one is LINKED, UNLINKED or + UNRESOLVED (L + U + R = N). Every unresolved one may complete either way, so + the linked count k ranges over L..L+R and each k is mapped through the + unchanged predicates (k = N FULLY_LINKED, 2k >= N MIXED, else SCATTERED). + With R = 0 the band is exact as before. With R > 0 a band that every + completion reaches is ``DEGRADED / / EXACT``; completions that cross a + boundary are ``UNKNOWN`` with no band, because Direction declares no order to + pick a representative by. A singleton FULLY_LINKED is impossible with R > 0. + +The open-target count the V1 rule classified by is still derived, for +presentation only; the judgement forbids it as the classifier's authority. """ from __future__ import annotations +from typing import Any + from ..observations import ObservationSet -from .common import EVAL_AVAILABLE, SEM_EXACT, VitalResult, as_int, input_meta, unknown_result +from .common import ( + EVAL_AVAILABLE, + EVAL_DEGRADED, + EVAL_UNKNOWN, + SEM_EXACT, + SEM_SUPERSET, + InadmissibleEvidence, + VitalResult, + as_int, + input_meta, + unknown_result, +) from .horizon import CAP_SUPPORTED_UNUSED, CAP_UNSUPPORTED, CAPABILITY VITAL_ID = "direction" VITAL_VERSION = "PV-VITALS-V1-002/direction" -RULE_ID = "direction.bands.v1.1" +RULE_ID = "direction.bands.v2" +CLOSED_TARGET_RULE = "PV-REV-DIRECTION-CLOSED-TARGET-001" +INCOMPLETE_RULE = "PV-DIRECTION-INCOMPLETE-001" BANDS = ["NO_ACTIVE_CHANGE", "UNDECLARED", "SCATTERED", "MIXED", "FULLY_LINKED"] ACTIVE = "planning.linkage.active_change_requests_count_28d" -LINKED = "planning.linkage.active_change_requests_linked_to_open_target_count_28d" +LINKED = "planning.linkage.active_change_requests_linked_count_28d" +UNLINKED = "planning.linkage.active_change_requests_unlinked_count_28d" +UNRESOLVED = "planning.linkage.active_change_requests_unresolved_count_28d" +UNRESOLVED_ITEMS = "planning.linkage.unresolved_change_requests_28d" +MISSING_REFS = "planning.linkage.missing_target_reference_count_28d" TARGET_LINKS = "planning.linkage.links_per_target_28d" -IDS = [ACTIVE, LINKED, CAPABILITY, TARGET_LINKS] +# Presentation only since this rule version: never read to classify. +LINKED_TO_OPEN = "planning.linkage.active_change_requests_linked_to_open_target_count_28d" +IDS = [ACTIVE, LINKED, UNLINKED, UNRESOLVED, CAPABILITY, TARGET_LINKS, MISSING_REFS, UNRESOLVED_ITEMS] SHARED = ["PLANNING_TARGETS", "CHANGE_REQUEST_ACTIVITY"] GROUPS = ["HORIZON_DIRECTION_PLANNING", "DIRECTION_PULSE_ACTIVITY"] +BAND_INVARIANT = "DIRECTION_INCOMPLETE_BAND_INVARIANT" +AMBIGUOUS = "DIRECTION_INCOMPLETE_AMBIGUOUS" +REFERENCE_MISSING = "DIRECTION_TARGET_REFERENCE_MISSING" +SINGLE_TARGET = "ALL_LINKS_TO_SINGLE_TARGET" +COUNTS_INCONSISTENT = "DIRECTION_LINKAGE_COUNTS_INCONSISTENT" + + +def band_for(linked: int, active: int) -> str: + """The V1 predicates over complete linkage, with unlinked = active - linked.""" + if linked == active: + return "FULLY_LINKED" + if 2 * linked >= active: + return "MIXED" + return "SCATTERED" -def _result(obs: ObservationSet, band: str, derived: dict, explanation: str, diagnostics: list[str]) -> VitalResult: + +def reachable_bands(linked: int, unresolved: int, active: int) -> list[str]: + """Every band some completion of the unresolved change requests reaches, in canonical order.""" + reached = {band_for(k, active) for k in range(linked, linked + unresolved + 1)} + return [band for band in BANDS if band in reached] + + +def _result( + obs: ObservationSet, + band: str | None, + status: str, + semantics: str | None, + possible: list[str] | None, + derived: dict[str, Any], + explanation: str, + diagnostics: list[str], +) -> VitalResult: return VitalResult( vital_id=VITAL_ID, vital_version=VITAL_VERSION, rule_id=RULE_ID, band=band, - evaluation_status=EVAL_AVAILABLE, - band_semantics=SEM_EXACT, - possible_bands=None, + evaluation_status=status, + band_semantics=semantics, + possible_bands=possible, inputs=input_meta(obs, IDS), derived=derived, shared_signal_groups=SHARED, @@ -42,56 +114,94 @@ def _result(obs: ObservationSet, band: str, derived: dict, explanation: str, dia ) +def _missing(obs: ObservationSet, ids: list[str]) -> list[str]: + return [f"MISSING_REQUIRED:{oid}:{obs.status_of(oid)}/{obs.freshness_of(oid)}" for oid in ids if not obs.is_good(oid)] + + +def _count(obs: ObservationSet, oid: str) -> int: + value = as_int(obs.value_of(oid)) + if value < 0: + raise InadmissibleEvidence(COUNTS_INCONSISTENT, f"{oid} is {value}, a count below zero") + return value + + def evaluate(obs: ObservationSet) -> VitalResult: - if not obs.is_good(ACTIVE): - return unknown_result( - VITAL_ID, VITAL_VERSION, RULE_ID, obs, IDS, - [f"MISSING_REQUIRED:{ACTIVE}:{obs.status_of(ACTIVE)}/{obs.freshness_of(ACTIVE)}"], - SHARED, GROUPS, - ) - active = as_int(obs.value_of(ACTIVE)) + missing = _missing(obs, [ACTIVE]) + if missing: + return unknown_result(VITAL_ID, VITAL_VERSION, RULE_ID, obs, IDS, missing, SHARED, GROUPS) + active = _count(obs, ACTIVE) if active == 0: - return _result(obs, "NO_ACTIVE_CHANGE", {"active_change_count_28d": 0}, "No active change requests in the 28-day frame.", []) + return _result(obs, "NO_ACTIVE_CHANGE", EVAL_AVAILABLE, SEM_EXACT, None, {"active_change_count_28d": 0}, "No active change requests in the 28-day frame.", []) - if not obs.is_good(CAPABILITY): - return unknown_result( - VITAL_ID, VITAL_VERSION, RULE_ID, obs, IDS, - [f"MISSING_REQUIRED:{CAPABILITY}:{obs.status_of(CAPABILITY)}/{obs.freshness_of(CAPABILITY)}"], - SHARED, GROUPS, - ) + missing = _missing(obs, [CAPABILITY]) + if missing: + # DIR-INCOMPLETE-13: linkage capability neither positively present nor absent. + return unknown_result(VITAL_ID, VITAL_VERSION, RULE_ID, obs, IDS, missing, SHARED, GROUPS) capability = obs.value_of(CAPABILITY) if capability in (CAP_UNSUPPORTED, CAP_SUPPORTED_UNUSED): return _result( obs, "UNDECLARED", + EVAL_AVAILABLE, + SEM_EXACT, + None, {"active_change_count_28d": active, "capability": capability}, f"{active} active change requests, but no explicit planning target or linkage mechanism is declared.", [f"PLANNING_CAPABILITY:{capability}"], ) - if not obs.is_good(LINKED): - return unknown_result( - VITAL_ID, VITAL_VERSION, RULE_ID, obs, IDS, - [f"MISSING_REQUIRED:{LINKED}:{obs.status_of(LINKED)}/{obs.freshness_of(LINKED)}"], - SHARED, GROUPS, - ) - linked = as_int(obs.value_of(LINKED)) - unlinked = active - linked - if unlinked > linked: - band = "SCATTERED" - elif unlinked > 0: - band = "MIXED" - else: - band = "FULLY_LINKED" + + missing = _missing(obs, [LINKED, UNLINKED, UNRESOLVED]) + if missing: + return unknown_result(VITAL_ID, VITAL_VERSION, RULE_ID, obs, IDS, missing, SHARED, GROUPS) + linked, unlinked, unresolved = _count(obs, LINKED), _count(obs, UNLINKED), _count(obs, UNRESOLVED) + if linked + unlinked + unresolved != active: + # PV-DIRECTION-INCOMPLETE-001 section 7: invalid normalized input, never classified. + raise InadmissibleEvidence(COUNTS_INCONSISTENT, f"linked {linked} + unlinked {unlinked} + unresolved {unresolved} != active {active}") + links = obs.value_of(TARGET_LINKS) if obs.is_good(TARGET_LINKS) else None - derived = { + missing_refs = as_int(obs.value_of(MISSING_REFS)) if obs.is_good(MISSING_REFS) else None + derived: dict[str, Any] = { "active_change_count_28d": active, "linked_active_change_count_28d": linked, "unlinked_active_change_count_28d": unlinked, + "unresolved_active_change_count_28d": unresolved, "capability": capability, "links_per_target": links, + "linkage_rule": CLOSED_TARGET_RULE, } + if obs.is_good(LINKED_TO_OPEN): + derived["linked_to_open_target_count_28d"] = as_int(obs.value_of(LINKED_TO_OPEN)) + if missing_refs is not None: + derived["missing_target_reference_count_28d"] = missing_refs + diagnostics: list[str] = [] if isinstance(links, dict) and len(links) == 1 and linked > 1: - diagnostics.append("ALL_LINKS_TO_SINGLE_TARGET") - explanation = f"{linked} of {active} active change requests are explicitly linked to an open planning target." - return _result(obs, band, derived, explanation, diagnostics) + diagnostics.append(SINGLE_TARGET) + if missing_refs: + # DIR-INCOMPLETE-07: a positively missing target is a broken reference, counted unlinked. + diagnostics.append(f"{REFERENCE_MISSING}:{missing_refs}") + + if unresolved == 0: + band = band_for(linked, active) + explanation = f"{linked} of {active} active change requests are explicitly linked to a declared target, open or closed." + return _result(obs, band, EVAL_AVAILABLE, SEM_EXACT, None, derived, explanation, diagnostics) + + reachable = reachable_bands(linked, unresolved, active) + derived["reachable_bands"] = reachable + derived["incomplete_rule"] = INCOMPLETE_RULE + items = obs.value_of(UNRESOLVED_ITEMS) if obs.is_good(UNRESOLVED_ITEMS) else None + if isinstance(items, dict): + derived["unresolved_change_requests"] = items + if len(reachable) == 1: + band = reachable[0] + derived["possible_bands_semantics"] = SEM_SUPERSET + return _result( + obs, band, EVAL_DEGRADED, SEM_EXACT, [band], derived, + f"{linked} of {active} active change requests are linked and {unresolved} unresolved; every completion of the unresolved ones is {band}.", + diagnostics + [BAND_INVARIANT], + ) + return _result( + obs, None, EVAL_UNKNOWN, None, None, derived, + f"{linked} of {active} active change requests are linked and {unresolved} unresolved; completing them could give {', '.join(reachable)}, and Direction declares no order to choose one.", + diagnostics + [AMBIGUOUS], + ) diff --git a/src/devostasis/vitals/horizon.py b/src/devostasis/vitals/horizon.py index 3760029..22183e6 100644 --- a/src/devostasis/vitals/horizon.py +++ b/src/devostasis/vitals/horizon.py @@ -2,16 +2,44 @@ Horizon measures forward visibility, not roadmap quality. EXTENDED is not better than VISIBLE; UNDECLARED may be entirely appropriate. + +Rule ``horizon.bands.v2`` adopts **PV-HORIZON-PARTIAL-001** (accepted by +PV-REV-HORIZON-PARTIAL-001, HOR-PARTIAL-01..16) on top of the V1 band table. +When the target enumeration is incomplete, its counts are lower bounds over +the returned targets (each carrying the observed-subset proof of +PV-REV-PR-031-003), and every admissible completion of the missing targets is +classified with the unchanged predicates. Horizon declares no order, so a +completion set that reaches several bands names none of them (``UNKNOWN``); +only a set of one band is a band, ``DEGRADED / / EXACT``. Under an +ordinary partial enumeration that happens exactly when a returned open target +already lies beyond the 28-day frame, because adding targets cannot remove it +(existential monotonicity, not an ordering). A partial zero is never +UNDECLARED. Counts that contradict each other are refused before +classification (HOR-PARTIAL-15). """ from __future__ import annotations -from ..observations import ObservationSet -from .common import EVAL_AVAILABLE, SEM_EXACT, VitalResult, as_int, input_meta, unknown_result +from typing import Any + +from ..observations import FRESH, PARTIAL, ObservationSet, subset_count_problem +from .common import ( + EVAL_AVAILABLE, + EVAL_DEGRADED, + EVAL_UNKNOWN, + SEM_EXACT, + SEM_SUPERSET, + InadmissibleEvidence, + VitalResult, + as_int, + input_meta, + unknown_result, +) VITAL_ID = "horizon" VITAL_VERSION = "PV-VITALS-V1-002/horizon" -RULE_ID = "horizon.bands.v1" +RULE_ID = "horizon.bands.v2" +PARTIAL_RULE = "PV-HORIZON-PARTIAL-001" BANDS = ["UNDECLARED", "DECLARED", "VISIBLE", "EXTENDED"] CAPABILITY = "planning.explicit_targets.capability" @@ -20,6 +48,7 @@ BEYOND = "planning.explicit_targets.open_beyond_28d_count" NEAREST = "planning.explicit_targets.nearest_future_boundary_days" IDS = [CAPABILITY, OPEN, FUTURE, BEYOND, NEAREST] +COUNTS = (OPEN, FUTURE, BEYOND) SHARED = ["PLANNING_TARGETS"] GROUPS = ["HORIZON_DIRECTION_PLANNING"] @@ -28,16 +57,64 @@ CAP_SUPPORTED_UNUSED = "SUPPORTED_UNUSED" CAP_UNSUPPORTED = "UNSUPPORTED" +BAND_INVARIANT = "HORIZON_PARTIAL_BAND_INVARIANT" +AMBIGUOUS = "HORIZON_PARTIAL_AMBIGUOUS" +COUNTS_INCONSISTENT = "HORIZON_COUNTS_INCONSISTENT" + + +def classify(open_count: int, future: int, beyond: int) -> str: + """The V1 first-match table over complete counts.""" + if beyond > 0: + return "EXTENDED" + if future > 0: + return "VISIBLE" + if open_count > 0: + return "DECLARED" + return "UNDECLARED" + + +def reachable_bands(open_count: int, future: int, beyond: int, exact: tuple[bool, bool, bool]) -> list[str]: + """Every band some admissible completion reaches, in canonical order. + + A count that is exact keeps its value; a lower bound may grow. Every + completion keeps beyond <= future <= open. The table reads only whether + each count is positive, so raising a lower bound by one, or to the count + it must cover, reaches every region a larger completion could. + """ + exact_open, exact_future, exact_beyond = exact + + def grow(value: int, is_exact: bool, floor: int = 0) -> set[int]: + if is_exact: + return {value} + return {max(value, floor), max(value, floor) + 1} + + reached: set[str] = set() + for b in grow(beyond, exact_beyond): + for f in grow(future, exact_future, b): + for o in grow(open_count, exact_open, f): + if b <= f <= o: + reached.add(classify(o, f, b)) + return [band for band in BANDS if band in reached] + -def _result(obs: ObservationSet, band: str, derived: dict, explanation: str, diagnostics: list[str]) -> VitalResult: +def _result( + obs: ObservationSet, + band: str | None, + status: str, + semantics: str | None, + possible: list[str] | None, + derived: dict[str, Any], + explanation: str, + diagnostics: list[str], +) -> VitalResult: return VitalResult( vital_id=VITAL_ID, vital_version=VITAL_VERSION, rule_id=RULE_ID, band=band, - evaluation_status=EVAL_AVAILABLE, - band_semantics=SEM_EXACT, - possible_bands=None, + evaluation_status=status, + band_semantics=semantics, + possible_bands=possible, inputs=input_meta(obs, IDS), derived=derived, shared_signal_groups=SHARED, @@ -47,8 +124,28 @@ def _result(obs: ObservationSet, band: str, derived: dict, explanation: str, dia ) +def _subset_lower_bound(obs: ObservationSet, oid: str) -> str | None: + """None when a PARTIAL count is a fresh observed-subset lower bound, else why it is not.""" + item = obs.get(oid) + if item is None or item.status != PARTIAL or item.freshness != FRESH: + return f"MISSING_REQUIRED:{oid}:{obs.status_of(oid)}/{obs.freshness_of(oid)}" + problem = subset_count_problem(item) + return None if problem is None else f"PARTIAL_COUNT_NOT_A_LOWER_BOUND:{oid}:{problem}" + + +def _checked(values: dict[str, int]) -> None: + """HOR-PARTIAL-15: a derived tuple no target population can produce is refused, not compensated.""" + open_count, future, beyond = values[OPEN], values[FUTURE], values[BEYOND] + if min(open_count, future, beyond) < 0 or not beyond <= future <= open_count: + raise InadmissibleEvidence( + COUNTS_INCONSISTENT, + f"open {open_count}, with a future boundary {future}, beyond 28 days {beyond}: every target beyond the frame has a future boundary and every such target is open", + ) + + def evaluate(obs: ObservationSet) -> VitalResult: if not obs.is_good(CAPABILITY): + # HOR-PARTIAL-06: support versus positive absence is not established. return unknown_result( VITAL_ID, VITAL_VERSION, RULE_ID, obs, IDS, [f"MISSING_REQUIRED:{CAPABILITY}:{obs.status_of(CAPABILITY)}/{obs.freshness_of(CAPABILITY)}"], @@ -59,32 +156,58 @@ def evaluate(obs: ObservationSet) -> VitalResult: return _result( obs, "UNDECLARED", + EVAL_AVAILABLE, + SEM_EXACT, + None, {"capability": capability, "open_count": 0, "open_with_future_boundary_count": 0, "open_beyond_28d_count": 0}, "No explicit planning targets are declared for this repository.", [f"PLANNING_CAPABILITY:{capability}"], ) - missing = [f"MISSING_REQUIRED:{oid}:{obs.status_of(oid)}/{obs.freshness_of(oid)}" for oid in (OPEN, FUTURE, BEYOND) if not obs.is_good(oid)] - if missing: - return unknown_result(VITAL_ID, VITAL_VERSION, RULE_ID, obs, IDS, missing, SHARED, GROUPS) - open_count = as_int(obs.value_of(OPEN)) - future = as_int(obs.value_of(FUTURE)) - beyond = as_int(obs.value_of(BEYOND)) + problems = [] + for oid in COUNTS: + if not obs.is_good(oid): + problem = _subset_lower_bound(obs, oid) + if problem is not None: + problems.append(problem) + if problems: + return unknown_result(VITAL_ID, VITAL_VERSION, RULE_ID, obs, IDS, problems, SHARED, GROUPS) + + values = {oid: as_int(obs.value_of(oid)) for oid in COUNTS} + _checked(values) + open_count, future, beyond = values[OPEN], values[FUTURE], values[BEYOND] nearest = obs.value_of(NEAREST) if obs.is_good(NEAREST) else None - if beyond > 0: - band = "EXTENDED" - elif future > 0: - band = "VISIBLE" - elif open_count > 0: - band = "DECLARED" - else: - band = "UNDECLARED" - derived = { + derived: dict[str, Any] = { "capability": capability, "open_count": open_count, "open_with_future_boundary_count": future, "open_beyond_28d_count": beyond, "nearest_future_boundary_days": nearest, } - explanation = f"{open_count} open planning targets, {future} with a future boundary, {beyond} reaching beyond 28 days." - return _result(obs, band, derived, explanation, []) + exact = tuple(obs.is_good(oid) for oid in COUNTS) + if all(exact): + band = classify(open_count, future, beyond) + explanation = f"{open_count} open planning targets, {future} with a future boundary, {beyond} reaching beyond 28 days." + return _result(obs, band, EVAL_AVAILABLE, SEM_EXACT, None, derived, explanation, []) + + # PV-HORIZON-PARTIAL-001: the counts of a partial enumeration are lower bounds. + for oid, is_exact in zip(COUNTS, exact): + if not is_exact: + derived[f"{oid.rsplit('.', 1)[1]}_semantics"] = "LOWER_BOUND" + reachable = reachable_bands(open_count, future, beyond, exact) + derived["reachable_bands"] = reachable + derived["partial_rule"] = PARTIAL_RULE + partial = [f"PARTIAL_ENUMERATION:{oid}:{obs.get(oid).reason_code or 'INCOMPLETE'}" for oid, is_exact in zip(COUNTS, exact) if not is_exact] + if len(reachable) == 1: + band = reachable[0] + derived["possible_bands_semantics"] = SEM_SUPERSET + return _result( + obs, band, EVAL_DEGRADED, SEM_EXACT, [band], derived, + f"At least {open_count} open planning targets were returned by an incomplete enumeration, {beyond} of them beyond 28 days; every completion of the missing targets is {band}.", + partial + [BAND_INVARIANT], + ) + return _result( + obs, None, EVAL_UNKNOWN, None, None, derived, + f"At least {open_count} open planning targets were returned by an incomplete enumeration; the missing ones could make Horizon {', '.join(reachable)}, and Horizon declares no order to choose one.", + partial + [AMBIGUOUS], + ) diff --git a/tests/conformance/test_planning_and_debt.py b/tests/conformance/test_planning_and_debt.py index 599d2c4..c5e753e 100644 --- a/tests/conformance/test_planning_and_debt.py +++ b/tests/conformance/test_planning_and_debt.py @@ -1,5 +1,7 @@ """Horizon, Direction and Debt (V1-03..V1-07, V1-13..V1-15).""" +import pytest + from devostasis.observations import FORBIDDEN, PARTIAL from devostasis.vitals import debt, direction, horizon from helpers import add, debt_inputs, direction_inputs, obs_set, planning_inputs @@ -89,22 +91,39 @@ def test_configured_debt_with_forbidden_register_is_unknown_never_clear(): assert result.band is None and result.evaluation_status == "UNKNOWN" +SUBSET = {"complete": False, "value_semantics": "OBSERVED_SUBSET_COUNT"} +MAPPING = {"source": "labels", "labels": ["debt"], "mapping_version": "1"} + + def test_partial_register_with_observed_items_is_degraded_present(): + """PD1 of PV-DEBT-PARTIAL-001: the band is exact, the acquisition is not.""" obs = obs_set() add(obs, "debt.registry.capability", "CONFIGURED", "enum") - add(obs, "debt.items.open_count", 3, status=PARTIAL, reason_code="PAGINATION_CAPPED") + add(obs, "debt.mapping", MAPPING, "record") + add(obs, "debt.items.open_count", 3, status=PARTIAL, reason_code="PAGINATION_CAPPED", coverage=SUBSET) result = debt.evaluate(obs) - assert result.band == "PRESENT" and result.evaluation_status == "DEGRADED" + assert (result.band, result.evaluation_status, result.band_semantics, result.possible_bands) == ("PRESENT", "DEGRADED", "EXACT", None) + assert result.derived["open_count_semantics"] == "LOWER_BOUND" and result.derived["confirmed_open_count"] == 3 def test_a_stale_partial_register_proves_nothing_about_now(): """vitals.md: configured debt evidence that is stale yields UNKNOWN; a fresh partial one is a lower bound.""" - from devostasis.observations import PARTIAL - for freshness, expected in (("FRESH", "PRESENT"), ("STALE", None), ("UNKNOWN", None)): obs = obs_set() add(obs, "debt.registry.capability", "CONFIGURED", "enum") - add(obs, "debt.mapping", {"source": "labels", "labels": ["debt"], "mapping_version": "1"}, "record") - add(obs, "debt.items.open_count", 2, status=PARTIAL, freshness=freshness, reason_code="PAGINATION_CAPPED") + add(obs, "debt.mapping", MAPPING, "record") + add(obs, "debt.items.open_count", 2, status=PARTIAL, freshness=freshness, reason_code="PAGINATION_CAPPED", coverage=SUBSET) result = debt.evaluate(obs) assert result.band == expected, freshness + + +@pytest.mark.parametrize("coverage", [None, {"complete": False}, {"complete": False, "value_semantics": "ESTIMATE"}, {"complete": True, "value_semantics": "OBSERVED_SUBSET_COUNT"}, "not a record"]) +def test_a_partial_debt_count_without_the_subset_proof_is_not_a_lower_bound(coverage): + """Issue #39: status PARTIAL proves nothing about the value; only a proven observed-subset count is a floor.""" + obs = obs_set() + add(obs, "debt.registry.capability", "CONFIGURED", "enum") + add(obs, "debt.mapping", MAPPING, "record") + add(obs, "debt.items.open_count", 4, status=PARTIAL, reason_code="PAGINATION_CAPPED", coverage=coverage) + result = debt.evaluate(obs) + assert (result.band, result.evaluation_status) == (None, "UNKNOWN") + assert any(code.startswith("PARTIAL_COUNT_NOT_A_LOWER_BOUND:debt.items.open_count") for code in result.diagnostics) diff --git a/tests/helpers.py b/tests/helpers.py index 16538f8..3d2349e 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -164,9 +164,13 @@ def planning_inputs(obs: ObservationSet, capability: str, open_count: int = 0, f add(obs, "planning.explicit_targets.nearest_future_boundary_days", nearest, "duration") -def direction_inputs(obs: ObservationSet, active: int, linked: int | None = None, links: dict[str, int] | None = None) -> None: +def direction_inputs(obs: ObservationSet, active: int, linked: int | None = None, links: dict[str, int] | None = None, unresolved: int = 0) -> None: + """Direction's inputs: the active population and, when ``linked`` is given, its state-neutral L/U/R split.""" add(obs, "planning.linkage.active_change_requests_count_28d", active) if linked is not None: + add(obs, "planning.linkage.active_change_requests_linked_count_28d", linked) + add(obs, "planning.linkage.active_change_requests_unlinked_count_28d", active - linked - unresolved) + add(obs, "planning.linkage.active_change_requests_unresolved_count_28d", unresolved) add(obs, "planning.linkage.active_change_requests_linked_to_open_target_count_28d", linked) if links is not None: add(obs, "planning.linkage.links_per_target_28d", links, "record") diff --git a/tests/test_bundle_history.py b/tests/test_bundle_history.py index afad62b..bad60b8 100644 --- a/tests/test_bundle_history.py +++ b/tests/test_bundle_history.py @@ -9,6 +9,7 @@ from devostasis.contracts import ARTIFACT_CONTRACT_VERSION, RENDERER_VERSION from devostasis.config import single_project from devostasis.history import FilesystemHistoryStore, ImmutabilityError +from devostasis.observations import Observation from devostasis.runner import build_from_observations from helpers import OBSERVED_AT, add, finalize, full_inputs, obs_set @@ -184,7 +185,9 @@ def test_rule_version_boundary_makes_one_vital_incomparable_inside_a_comparable_ def test_art_06_neutral_rendering_of_fully_linked_and_present(tmp_path): obs = obs_set() full_inputs(obs) - obs.replace(type(obs.get("planning.linkage.active_change_requests_linked_to_open_target_count_28d"))(**dict(obs.get("planning.linkage.active_change_requests_linked_to_open_target_count_28d").to_dict(), value=10))) + for key, value in (("linked", 10), ("unlinked", 0)): + observation_id = f"planning.linkage.active_change_requests_{key}_count_28d" + obs.replace(Observation.from_dict(dict(obs.get(observation_id).to_dict(), value=value))) bundle = build_from_observations(_project(), obs, FilesystemHistoryStore(tmp_path)) report = bundle.members["report.md"].decode("utf-8") assert "FULLY_LINKED" in report and "PRESENT" in report diff --git a/tests/test_demand_display_registers.py b/tests/test_demand_display_registers.py index 375b7f3..bf484ce 100644 --- a/tests/test_demand_display_registers.py +++ b/tests/test_demand_display_registers.py @@ -160,14 +160,20 @@ def test_file_registers_feed_horizon_direction_and_debt(): assert obs.get(INV_TARGETS).value[1]["target_id"] == "T-3" and obs.get(INV_DEBT_REGISTER).value[0]["id"] == "D-1" crs = {c["number"]: c for c in obs.value_of("forge.change_requests.inventory")} assert crs[7]["target_refs"] == [{"target_id": "T-3", "state": "OPEN"}] - assert crs[8]["target_refs"] == [{"target_id": "T-9", "state": "UNKNOWN"}] + # T-9 is not in the complete register: a broken reference, not missing evidence (DIR-INCOMPLETE-07). + assert crs[8]["target_refs"] == [{"target_id": "T-9", "state": "MISSING"}] derive(obs, project) assert obs.value_of("planning.explicit_targets.capability") == "SUPPORTED" + assert obs.value_of("planning.linkage.active_change_requests_linked_count_28d") == 1 + assert obs.value_of("planning.linkage.active_change_requests_unlinked_count_28d") == 1 assert obs.value_of("planning.linkage.active_change_requests_linked_to_open_target_count_28d") == 1 - assert obs.value_of("planning.linkage.unknown_target_reference_count_28d") == 1 + assert obs.value_of("planning.linkage.unknown_target_reference_count_28d") == 0 + assert obs.value_of("planning.linkage.missing_target_reference_count_28d") == 1 assert obs.value_of("debt.items.open_count") == 1 and obs.value_of("debt.items.open_stale_count_30d") == 1 and obs.value_of("debt.items.closed_count_28d") == 1 bands = {r.vital_id: r.band for r in evaluate_all(obs)} assert bands["horizon"] == "EXTENDED" and bands["direction"] == "MIXED" and bands["debt"] == "PRESENT" + direction = {r.vital_id: r for r in evaluate_all(obs)}["direction"] + assert "DIRECTION_TARGET_REFERENCE_MISSING:1" in direction.diagnostics def test_missing_register_is_explicit_not_undeclared(): diff --git a/tests/test_github_adapter.py b/tests/test_github_adapter.py index 20664b7..7a0a260 100644 --- a/tests/test_github_adapter.py +++ b/tests/test_github_adapter.py @@ -4,7 +4,7 @@ import pytest -from devostasis.adapters.github import GitHubAdapter, GitHubClient, LinkageEvidenceError, classify_http_error +from devostasis.adapters.github import GitHubAdapter, GitHubClient, classify_http_error from devostasis.config import single_project from devostasis.normalize import CI_CONFIGURED, CI_REVISIONS, INV_BRANCHES, INV_CRS, INV_ISSUES, INV_TARGETS, derive from devostasis.observations import AVAILABLE, ERROR, FORBIDDEN, PARTIAL, UNAVAILABLE @@ -301,7 +301,7 @@ def __exit__(self, *exc): raise AssertionError("a 200 with a non-JSON body must be a declared failure") -# --------------------------------------------------------------------------- linkage evidence (PV-REV-PR-015) +# --------------------------------------------------------------------------- linkage evidence (PV-REV-PR-015, PV-DIRECTION-INCOMPLETE-001) def _pull(number=7, **fields): @@ -332,24 +332,56 @@ def _observe(routes, project): return GitHubAdapter(GitHubClient(FakeTransport(routes)), NOW).collect(project) +def _register_route(ids_and_states): + import base64 + import json as _json + + document = {"schema": "devostasis.targets.v1", "targets": [{"id": tid, "state": state} for tid, state in ids_and_states]} + content = base64.b64encode(_json.dumps(document).encode("utf-8")).decode("ascii") + return 200, {}, {"type": "file", "size": len(content), "encoding": "base64", "content": content} + + +def _file_planning_routes(pulls, register=(("B1", "open"),)): + return _routes(**{f"{BASE}/pulls": _paged(pulls), f"{BASE}/contents/.devostasis/targets.json": _register_route(register)}) + + @pytest.mark.parametrize("field, value", [("title", 7), ("title", ["a"]), ("body", {"a": 1}), ("body", 3.5)]) -def test_unreadable_linkage_evidence_is_declared_not_read_as_unlinked(field, value): - """`Target: ` lives in the title and the body, so payload of the wrong type there is not an absent link.""" - routes = _routes(**{f"{BASE}/pulls": _paged([_pull(**{field: value})])}) - try: - _observe(routes, _file_planning_project()) - except LinkageEvidenceError as exc: - assert "#7" in str(exc) and field in str(exc) - else: - raise AssertionError(f"a {type(value).__name__} {field} must not silently become an unlinked change request") +def test_unreadable_linkage_evidence_is_unresolved_not_unlinked_and_not_a_failed_project(field, value): + """`Target: ` lives in the title and the body, so payload of the wrong type there is not an absent link. + + Since PV-DIRECTION-INCOMPLETE-001 it is not a failed project either: the + change request keeps what could be read, names what could not, and enters + Direction as UNRESOLVED instead of UNLINKED. + """ + project = _file_planning_project() + obs = _observe(_file_planning_routes([_pull(**{field: value})]), project) + record = obs.get(INV_CRS).value[0] + assert record["target_refs"] == [] and record["linkage_unresolved"] == [f"{field}:{type(value).__name__}"] + derive(obs, project) + assert obs.value_of("planning.linkage.active_change_requests_unresolved_count_28d") == 1 + assert obs.value_of("planning.linkage.active_change_requests_unlinked_count_28d") == 0 + assert obs.value_of("planning.linkage.unresolved_change_requests_28d") == {"7": [f"LINKAGE_UNREADABLE:{field}:{type(value).__name__}"]} + direction = {r.vital_id: r for r in evaluate_all(obs)}["direction"] + assert (direction.band, direction.evaluation_status) == (None, "UNKNOWN"), "one unresolved of one could be FULLY_LINKED or SCATTERED" + + +def test_a_readable_link_is_not_undone_by_an_unreadable_field(): + """Unreadable evidence can only add references, and a resolved one already links the change request.""" + project = _file_planning_project() + obs = _observe(_file_planning_routes([_pull(title="Target: B1", body={"not": "text"})]), project) + record = obs.get(INV_CRS).value[0] + assert record["target_refs"] == [{"target_id": "B1", "state": "OPEN"}] and record["linkage_unresolved"] == ["body:dict"] + derive(obs, project) + assert obs.value_of("planning.linkage.active_change_requests_linked_count_28d") == 1 + assert obs.value_of("planning.linkage.active_change_requests_unresolved_count_28d") == 0 def test_a_change_request_with_no_marker_is_unlinked_and_that_is_not_an_error(): - """The distinction the error exists to preserve: absent evidence is a fact, unreadable evidence is not.""" + """The distinction the unresolved state exists to preserve: absent evidence is a fact, unreadable evidence is not.""" routes = _routes(**{f"{BASE}/pulls": _paged([_pull(title="nothing to see", body="no marker here")])}) item = _observe(routes, _file_planning_project()).get(INV_CRS) assert item.status == AVAILABLE - assert item.value[0]["target_refs"] == [] and item.value[0]["target_id"] is None + assert item.value[0]["target_refs"] == [] and item.value[0]["target_id"] is None and item.value[0]["linkage_unresolved"] == [] def test_a_marker_in_the_body_still_links_after_the_repair(): @@ -358,22 +390,35 @@ def test_a_marker_in_the_body_still_links_after_the_repair(): assert item.value[0]["target_id"] == "B1" -def test_a_milestone_without_a_number_cannot_name_the_target_it_links_to(): - """Under planning.source=milestones the milestone *is* the link; a malformed one is unreadable evidence.""" +def test_a_milestone_without_a_number_is_unresolved_linkage_not_a_failed_project(): + """Under planning.source=milestones the milestone *is* the link; a malformed one is unreadable evidence, kept as such.""" + project = single_project("acme/widget") routes = _routes(**{f"{BASE}/pulls": _paged([_pull(milestone={"state": "open"})])}) - try: - _observe(routes, single_project("acme/widget")) - except LinkageEvidenceError as exc: - assert "#7" in str(exc) - else: - raise AssertionError("a milestone with no number must not be read as no milestone") - - -def test_unreadable_linkage_evidence_is_a_collection_error_so_one_project_fails_alone(): - """It reaches run_project's declared boundary, not run_all's catch-all for the unanticipated.""" - from devostasis.adapters.github import CollectionError - - assert issubclass(LinkageEvidenceError, CollectionError) + obs = _observe(routes, project) + record = obs.get(INV_CRS).value[0] + assert record["target_refs"] == [] and record["linkage_unresolved"] == ["milestone:NO_NUMBER"] + derive(obs, project) + assert obs.value_of("planning.linkage.active_change_requests_unresolved_count_28d") == 1 + + +def test_a_reference_the_complete_register_lacks_is_missing_and_one_the_register_could_not_answer_is_unknown(): + """DIR-INCOMPLETE-07 against DIR-INCOMPLETE-09: positive absence is a broken reference, an unreadable register is no evidence.""" + project = _file_planning_project() + present = _observe(_file_planning_routes([_pull(body="Target: T-9")]), project) + assert present.get(INV_CRS).value[0]["target_refs"] == [{"target_id": "T-9", "state": "MISSING"}] + absent = _observe(_routes(**{f"{BASE}/pulls": _paged([_pull(body="Target: T-9")])}), project) + assert absent.get(INV_TARGETS).reason_code == "REGISTER_NOT_FOUND" + assert absent.get(INV_CRS).value[0]["target_refs"] == [{"target_id": "T-9", "state": "UNKNOWN"}] + + +def test_a_closed_register_target_is_still_a_resolved_reference(): + """DIR-CLOSED-03 at the adapter: the register resolves the id, and its lifecycle is carried, not used to unlink.""" + project = _file_planning_project() + obs = _observe(_file_planning_routes([_pull(body="Target: B1")], register=(("B1", "closed"),)), project) + assert obs.get(INV_CRS).value[0]["target_refs"] == [{"target_id": "B1", "state": "CLOSED"}] + derive(obs, project) + assert obs.value_of("planning.linkage.active_change_requests_linked_count_28d") == 1 + assert obs.value_of("planning.linkage.active_change_requests_linked_to_open_target_count_28d") == 0 # --------------------------------------------------------------------------- register shapes (#12 finding 5) diff --git a/tests/test_linkage_markers.py b/tests/test_linkage_markers.py new file mode 100644 index 0000000..e7883f9 --- /dev/null +++ b/tests/test_linkage_markers.py @@ -0,0 +1,93 @@ +"""Target markers and contradictory planning evidence (0.2.0). + +``PV-AUDIT-TARGET-MARKER-SYNTAX-001`` found the configured marker matched as a +substring, so ``NotTarget: B1`` and ``PreTarget:B1`` linked a change request to +B1. The audit's regressions ``LINK-MARKER-01..08`` are known here only by the +categories its handoff names (exact marker, embedded-prefix negatives, custom +markers, multiple markers, prose without the marker, end-to-end file +planning); these tests cover each category and do not claim the individual +case texts, which never reached this repository. +""" + +from __future__ import annotations + +import json + +import pytest + +from devostasis.adapters.github import GitHubAdapter, GitHubClient, marker_target_ids +from devostasis.cli import main +from devostasis.config import single_project +from devostasis.normalize import INV_CRS, derive +from devostasis.observations import ObservationSet +from devostasis.vitals import evaluate_all +from helpers import full_inputs, obs_set +from test_github_adapter import BASE, NOW, FakeTransport, _paged, _pull, _register_route, _routes + + +def test_an_exact_marker_links_wherever_it_stands_alone(): + for text in ("Target: B1", "Target:B1", "intro\nTarget: B1", "(Target: B1)", "- Target: B1", "done.\tTarget: B1", "Target: B1."): + assert marker_target_ids(text, "Target:") == ["B1"], text + + +@pytest.mark.parametrize("text", ["NotTarget: B1", "SubTarget: B1", "PreTarget:B1", "xTarget: B1", "_Target: B1", "2Target: B1", "ЯTarget: B1"]) +def test_a_marker_embedded_in_a_larger_token_links_nothing(text): + assert marker_target_ids(text, "Target:") == [] + + +def test_a_marker_ending_in_a_word_character_needs_a_boundary_after_it_too(): + assert marker_target_ids("Target B1", "Target") == ["B1"] + assert marker_target_ids("Targeted B1", "Target") == [], "the id would otherwise be read out of the word" + assert marker_target_ids("Targets: B1", "Target") == [] + + +def test_a_custom_marker_replaces_the_default_and_is_matched_exactly(): + assert marker_target_ids("Goal: release/1.2", "Goal:") == ["release/1.2"] + assert marker_target_ids("Target: B1", "Goal:") == [] + assert marker_target_ids("SubGoal: B1\nGoal: B2", "Goal:") == ["B2"] + hash_marker = "#target" + assert marker_target_ids(f"{hash_marker} B7", hash_marker) == ["B7"] + assert marker_target_ids(f"x{hash_marker} B7", hash_marker) == [] + + +def test_several_markers_keep_their_order_and_link_each_target_once(): + assert marker_target_ids("Target: B2\nTarget: B1\nTarget: B2", "Target:") == ["B2", "B1"] + assert marker_target_ids("NotTarget: B9 and Target: B3", "Target:") == ["B3"] + + +@pytest.mark.parametrize("text", ["target: B1", "TARGET: B1", "targets B1", "Fixes 12", "the target is B1", ""]) +def test_prose_without_the_configured_marker_links_nothing(text): + assert marker_target_ids(text, "Target:") == [] + + +def test_embedded_markers_do_not_link_end_to_end(): + """File planning end to end: only the standalone marker becomes a reference, and Direction counts only it.""" + project = single_project("acme/widget", planning={"source": "file", "path": ".devostasis/targets.json", "link_marker": "Target:"}) + pulls = [_pull(number=7, body="SubTarget: B1\nTarget: B2"), _pull(number=8, body="NotTarget: B1")] + register = _register_route((("B1", "open"), ("B2", "closed"))) + routes = _routes(**{f"{BASE}/pulls": _paged(pulls), f"{BASE}/contents/.devostasis/targets.json": register}) + obs = GitHubAdapter(GitHubClient(FakeTransport(routes)), NOW).collect(project) + refs = {item["number"]: item["target_refs"] for item in obs.get(INV_CRS).value} + assert refs == {7: [{"target_id": "B2", "state": "CLOSED"}], 8: []} + derive(obs, project) + direction = {r.vital_id: r for r in evaluate_all(obs)}["direction"] + assert (direction.band, direction.derived["linked_active_change_count_28d"], direction.derived["unlinked_active_change_count_28d"]) == ("MIXED", 1, 1) + + +@pytest.mark.parametrize("command", ["evaluate", "build"]) +def test_observations_whose_counts_contradict_each_other_are_an_input_error(tmp_path, capsys, command): + """PV-DIRECTION-INCOMPLETE-001 section 7: L + U + R != N is invalid normalized input, refused before classification.""" + data = full_inputs(obs_set()).to_dict() + for item in data["observations"]: + if item["observation_id"] == "planning.linkage.active_change_requests_unresolved_count_28d": + item["value"] = 5 + path = tmp_path / "observations.json" + path.write_text(json.dumps(data), encoding="utf-8") + ObservationSet.load(path) + if command == "evaluate": + argv = ["evaluate", "--observations", str(path), "--out", str(tmp_path / "snapshot.json")] + else: + argv = ["build", "--observations", str(path), "--store", str(tmp_path / "store")] + assert main(argv) == 2 + err = capsys.readouterr().err + assert "input error" in err and "DIRECTION_LINKAGE_COUNTS_INCONSISTENT" in err diff --git a/tests/test_normalize.py b/tests/test_normalize.py index 3e9c854..984e247 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -46,8 +46,15 @@ def test_change_request_aggregates(): assert obs.value_of("forge.change_requests.stale_open_count_14d") == 1 assert obs.value_of("forge.change_requests.updated_count_28d") == 3 assert obs.value_of("planning.linkage.active_change_requests_count_28d") == 3 + # Change request 4 delivered target 9, which has closed since; it stays + # linked (PV-REV-DIRECTION-CLOSED-TARGET-001), and only the presentation + # count of links to open targets leaves it out. + assert obs.value_of("planning.linkage.active_change_requests_linked_count_28d") == 3 + assert obs.value_of("planning.linkage.active_change_requests_unlinked_count_28d") == 0 + assert obs.value_of("planning.linkage.active_change_requests_unresolved_count_28d") == 0 assert obs.value_of("planning.linkage.active_change_requests_linked_to_open_target_count_28d") == 2 - assert obs.value_of("planning.linkage.links_per_target_28d") == {"5": 2} + assert obs.get("planning.linkage.active_change_requests_linked_to_open_target_count_28d").notes == normalize.LINKED_TO_OPEN_NOTE + assert obs.value_of("planning.linkage.links_per_target_28d") == {"5": 2, "9": 1} def test_flow_prec_01_sub_hour_medians_are_preserved(): diff --git a/tests/test_vector_runner.py b/tests/test_vector_runner.py index dc2cd96..cd3341c 100644 --- a/tests/test_vector_runner.py +++ b/tests/test_vector_runner.py @@ -162,7 +162,11 @@ def test_the_published_vector_schema_and_the_runner_agree_on_the_shape(): vital_expect = schema["$defs"]["vital_expect"] assert vital["expect"] == {"$ref": "#/$defs/vital_expect"} assert set(vital_expect["properties"]) == vectors.VITAL_EXPECT_KEYS - assert set(vital_expect["required"]) == vectors.REQUIRED_VITAL_EXPECT_KEYS + result_shape, refusal_shape = vital_expect["oneOf"] + assert set(result_shape["required"]) == vectors.REQUIRED_VITAL_EXPECT_KEYS and result_shape["not"] == {"required": ["rejected"]} + assert refusal_shape == {"required": ["rejected"], "maxProperties": 1}, "a refusal is the whole expectation" + assert set(vital["given"]["properties"]["derive"]["properties"]) == vectors.DERIVE_KEYS + assert set(vital["given"]["properties"]["derive"]["properties"]["planning_source"]["enum"]) == set(vectors.PLANNING_SOURCES) assert set(ci["given"]["properties"]) == vectors.CI_GIVEN_KEYS assert set(ci["given"]["properties"]["variants"]["items"]["properties"]) == vectors.CI_VARIANT_KEYS assert set(ci["given"]["properties"]["provider"]["enum"]) == set(vectors.CI_PROVIDERS) @@ -303,3 +307,48 @@ def test_an_exactly_empty_metric_delta_is_still_a_statement(): } vector = parse(kind="delta", given=comparison, expect={"vitals": {"clutter": {"transition_class": "UNCHANGED", "metric_deltas": []}}}) assert vector.kind == "delta" + + +# --------------------------------------------------------------------------- derive and rejected (0.2.0) + +HORIZON_COUNTS = [ + {"observation_id": "planning.explicit_targets.capability", "value_type": "enum", "value": "SUPPORTED"}, + {"observation_id": "planning.explicit_targets.open_count", "value": 1}, + {"observation_id": "planning.explicit_targets.open_with_future_boundary_count", "value": 1}, + {"observation_id": "planning.explicit_targets.open_beyond_28d_count", "value": 2}, +] + + +def test_derive_runs_the_derivation_before_the_vital_is_evaluated(): + """A case about an inventory is executed over the derivation, not over counts somebody pre-derived.""" + inventory = [{ + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [{"target_id": "1", "state": "OPEN", "due_at": None}], + "coverage": {"complete": True, "source": "milestones"}, + }] + given = {"vital": "horizon", "observations": inventory, "derive": {"planning_source": "milestones", "debt_mapping": None}} + assert vectors.run(parse(given=given, expect={"band": "DECLARED", "evaluation_status": "AVAILABLE", "derived": {"open_count": 1}})).ok + without = vectors.run(parse(given={"vital": "horizon", "observations": inventory}, expect={"band": "DECLARED", "evaluation_status": "AVAILABLE"})) + assert not without.ok, "without derive the inventory alone is not Horizon's input" + + +def test_derive_names_only_the_settings_the_derivation_reads(): + for derive in ({"planning_source": "jira"}, {"debt_mapping": None}, {"planning_source": "none", "extra": 1}, {"planning_source": "none", "debt_mapping": ["debt"]}): + with pytest.raises(vectors.VectorError): + parse(given={"vital": "horizon", "observations": HORIZON_COUNTS, "derive": derive}) + + +def test_a_rejected_case_passes_only_when_the_evidence_is_refused_with_that_code(): + given = {"vital": "horizon", "observations": HORIZON_COUNTS} + assert vectors.run(parse(given=given, expect={"rejected": "HORIZON_COUNTS_INCONSISTENT"})).ok + wrong_code = vectors.run(parse(given=given, expect={"rejected": "DIRECTION_LINKAGE_COUNTS_INCONSISTENT"})) + assert not wrong_code.ok and "HORIZON_COUNTS_INCONSISTENT" in wrong_code.failures[0] + admissible = vectors.run(parse(expect={"rejected": "HORIZON_COUNTS_INCONSISTENT"})) + assert not admissible.ok and "it evaluated to LIGHT / AVAILABLE" in admissible.failures[0] + + +def test_rejected_is_the_whole_expectation_and_names_a_code(): + for expect in ({"rejected": "X", "band": None}, {"rejected": ""}, {"rejected": 3}): + with pytest.raises(vectors.VectorError): + parse(given={"vital": "horizon", "observations": HORIZON_COUNTS}, expect=expect) diff --git a/tests/vectors/debt-partial.json b/tests/vectors/debt-partial.json new file mode 100644 index 0000000..8e34962 --- /dev/null +++ b/tests/vectors/debt-partial.json @@ -0,0 +1,1269 @@ +{ + "schema": "devostasis.vectors.v1", + "notes": "Cases DEBT-PARTIAL-01..16 of PV-DEBT-PARTIAL-001 (accepted by PV-REV-DEBT-PARTIAL-001), transcribed from the contract's required fixtures and adopted in 0.2.0 as debt.bands.v2 together with the observed-subset proof issue #39 asked for: a PARTIAL open count is a lower bound only when its coverage says OBSERVED_SUBSET_COUNT. Cases about the enumeration itself state the issue inventory under a label mapping and derive the counts; the others state the counts.", + "vectors": [ + { + "case": "DEBT-PARTIAL-01", + "title": "a partial enumeration with no confirmed open item cannot manufacture CLEAR", + "kind": "vital", + "source_unit": "PV-DEBT-PARTIAL-001", + "given": { + "vital": "debt", + "observed_at": "2026-09-07T12:00:00Z", + "observations": [ + { + "observation_id": "debt.registry.capability", + "value_type": "enum", + "value": "CONFIGURED" + }, + { + "observation_id": "debt.mapping", + "value_type": "record", + "value": { + "source": "labels", + "labels": [ + "debt" + ], + "mapping_version": "1" + } + }, + { + "observation_id": "debt.items.open_count", + "value": 0, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED", + "coverage": { + "complete": false, + "value_semantics": "OBSERVED_SUBSET_COUNT" + } + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "debt.bands.v2", + "derived": { + "open_count": 0, + "open_count_semantics": "LOWER_BOUND", + "confirmed_open_count": 0 + }, + "diagnostics": [ + "DEBT_PARTIAL_ENUMERATION", + "DEBT_CONFIRMED_OPEN_LOWER_BOUND:0" + ], + "diagnostics_absent": [ + "DEBT_BAND_FORCED_PRESENT_BY_CONFIRMED_OPEN" + ] + } + }, + { + "case": "DEBT-PARTIAL-02", + "title": "a returned subset of closed debt items only is still not CLEAR: the closed count is a diagnostic, omitted open items stay possible", + "kind": "vital", + "source_unit": "PV-DEBT-PARTIAL-001", + "given": { + "vital": "debt", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "none", + "debt_mapping": { + "source": "labels", + "labels": [ + "debt" + ], + "mapping_version": "1" + } + }, + "observations": [ + { + "observation_id": "forge.issues.inventory", + "value_type": "series", + "value": [ + { + "number": 4, + "title": "issue 4", + "state": "CLOSED", + "created_at": "2026-06-01T00:00:00Z", + "updated_at": "2026-09-01T00:00:00Z", + "closed_at": "2026-09-02T00:00:00Z", + "labels": [ + "debt" + ], + "target_id": null, + "linkage_unresolved": [] + }, + { + "number": 5, + "title": "issue 5", + "state": "CLOSED", + "created_at": "2026-06-01T00:00:00Z", + "updated_at": "2026-09-01T00:00:00Z", + "closed_at": "2026-09-03T00:00:00Z", + "labels": [ + "debt" + ], + "target_id": null, + "linkage_unresolved": [] + }, + { + "number": 6, + "title": "issue 6", + "state": "OPEN", + "created_at": "2026-06-01T00:00:00Z", + "updated_at": "2026-09-01T00:00:00Z", + "closed_at": null, + "labels": [ + "bug" + ], + "target_id": null, + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": false, + "window_complete": true + }, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED" + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "debt.bands.v2", + "derived": { + "open_count": 0, + "open_count_semantics": "LOWER_BOUND", + "confirmed_open_count": 0 + }, + "diagnostics": [ + "DEBT_PARTIAL_ENUMERATION", + "DEBT_CONFIRMED_OPEN_LOWER_BOUND:0" + ], + "diagnostics_absent": [ + "DEBT_BAND_FORCED_PRESENT_BY_CONFIRMED_OPEN" + ] + }, + "notes": "Every count over an incomplete issue inventory is PARTIAL, the closed one included, so it stays out of the result's metrics; it could not prove open emptiness even when complete (DEBT-PARTIAL-11)." + }, + { + "case": "DEBT-PARTIAL-03", + "title": "one confirmed open item forces PRESENT: DEGRADED / PRESENT / EXACT with the partial receipt retained", + "kind": "vital", + "source_unit": "PV-DEBT-PARTIAL-001", + "given": { + "vital": "debt", + "observed_at": "2026-09-07T12:00:00Z", + "observations": [ + { + "observation_id": "debt.registry.capability", + "value_type": "enum", + "value": "CONFIGURED" + }, + { + "observation_id": "debt.mapping", + "value_type": "record", + "value": { + "source": "labels", + "labels": [ + "debt" + ], + "mapping_version": "1" + } + }, + { + "observation_id": "debt.items.open_count", + "value": 1, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED", + "coverage": { + "complete": false, + "value_semantics": "OBSERVED_SUBSET_COUNT" + } + } + ] + }, + "expect": { + "band": "PRESENT", + "evaluation_status": "DEGRADED", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "debt.bands.v2", + "derived": { + "open_count": 1, + "open_count_semantics": "LOWER_BOUND", + "confirmed_open_count": 1, + "partial_enumeration": { + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED", + "source": null, + "source_reason_code": null + } + }, + "diagnostics": [ + "DEBT_PARTIAL_ENUMERATION", + "DEBT_CONFIRMED_OPEN_LOWER_BOUND:1", + "DEBT_BAND_FORCED_PRESENT_BY_CONFIRMED_OPEN" + ] + } + }, + { + "case": "DEBT-PARTIAL-04", + "title": "thirty-seven confirmed open items remain PRESENT, never ACCUMULATED or any quantitative severity", + "kind": "vital", + "source_unit": "PV-DEBT-PARTIAL-001", + "given": { + "vital": "debt", + "observed_at": "2026-09-07T12:00:00Z", + "observations": [ + { + "observation_id": "debt.registry.capability", + "value_type": "enum", + "value": "CONFIGURED" + }, + { + "observation_id": "debt.mapping", + "value_type": "record", + "value": { + "source": "labels", + "labels": [ + "debt" + ], + "mapping_version": "1" + } + }, + { + "observation_id": "debt.items.open_count", + "value": 37, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED", + "coverage": { + "complete": false, + "value_semantics": "OBSERVED_SUBSET_COUNT" + } + }, + { + "observation_id": "debt.items.open_stale_count_30d", + "value": 29 + }, + { + "observation_id": "debt.items.closed_count_28d", + "value": 3 + } + ] + }, + "expect": { + "band": "PRESENT", + "evaluation_status": "DEGRADED", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "debt.bands.v2", + "derived": { + "open_count": 37, + "open_count_semantics": "LOWER_BOUND", + "confirmed_open_count": 37, + "open_stale_count_30d": 29, + "closed_count_28d": 3 + }, + "diagnostics": [ + "DEBT_PARTIAL_ENUMERATION", + "DEBT_CONFIRMED_OPEN_LOWER_BOUND:37", + "DEBT_BAND_FORCED_PRESENT_BY_CONFIRMED_OPEN" + ] + } + }, + { + "case": "DEBT-PARTIAL-05", + "title": "the size of the omitted tail cannot erase a confirmed open item: two different partial receipts give the same band", + "kind": "vital", + "source_unit": "PV-DEBT-PARTIAL-001", + "given": { + "vital": "debt", + "observed_at": "2026-09-07T12:00:00Z", + "variants": [ + { + "title": "a page cap", + "observations": [ + { + "observation_id": "debt.registry.capability", + "value_type": "enum", + "value": "CONFIGURED" + }, + { + "observation_id": "debt.mapping", + "value_type": "record", + "value": { + "source": "labels", + "labels": [ + "debt" + ], + "mapping_version": "1" + } + }, + { + "observation_id": "debt.items.open_count", + "value": 1, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED", + "coverage": { + "complete": false, + "value_semantics": "OBSERVED_SUBSET_COUNT" + } + } + ] + }, + { + "title": "an exhausted request budget", + "observations": [ + { + "observation_id": "debt.registry.capability", + "value_type": "enum", + "value": "CONFIGURED" + }, + { + "observation_id": "debt.mapping", + "value_type": "record", + "value": { + "source": "labels", + "labels": [ + "debt" + ], + "mapping_version": "1" + } + }, + { + "observation_id": "debt.items.open_count", + "value": 1, + "status": "PARTIAL", + "reason_code": "REQUEST_BUDGET_EXHAUSTED", + "coverage": { + "complete": false, + "value_semantics": "OBSERVED_SUBSET_COUNT" + } + } + ] + } + ] + }, + "expect": { + "band": "PRESENT", + "evaluation_status": "DEGRADED", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "debt.bands.v2", + "derived": { + "open_count": 1, + "open_count_semantics": "LOWER_BOUND", + "confirmed_open_count": 1 + }, + "diagnostics": [ + "DEBT_PARTIAL_ENUMERATION", + "DEBT_CONFIRMED_OPEN_LOWER_BOUND:1", + "DEBT_BAND_FORCED_PRESENT_BY_CONFIRMED_OPEN" + ] + } + }, + { + "case": "DEBT-PARTIAL-06", + "title": "provider naming, item order, page order and page boundaries do not change the normalized result", + "kind": "vital", + "source_unit": "PV-DEBT-PARTIAL-001", + "given": { + "vital": "debt", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "none", + "debt_mapping": { + "source": "labels", + "labels": [ + "debt" + ], + "mapping_version": "1" + } + }, + "variants": [ + { + "title": "provider order", + "observations": [ + { + "observation_id": "forge.issues.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "issue 1", + "state": "OPEN", + "created_at": "2026-06-01T00:00:00Z", + "updated_at": "2026-09-01T00:00:00Z", + "closed_at": null, + "labels": [ + "debt" + ], + "target_id": null, + "linkage_unresolved": [] + }, + { + "number": 2, + "title": "issue 2", + "state": "OPEN", + "created_at": "2026-06-01T00:00:00Z", + "updated_at": "2026-09-01T00:00:00Z", + "closed_at": null, + "labels": [ + "debt", + "ops" + ], + "target_id": null, + "linkage_unresolved": [] + }, + { + "number": 3, + "title": "issue 3", + "state": "OPEN", + "created_at": "2026-06-01T00:00:00Z", + "updated_at": "2026-09-01T00:00:00Z", + "closed_at": null, + "labels": [ + "bug" + ], + "target_id": null, + "linkage_unresolved": [] + }, + { + "number": 4, + "title": "issue 4", + "state": "CLOSED", + "created_at": "2026-06-01T00:00:00Z", + "updated_at": "2026-09-01T00:00:00Z", + "closed_at": "2026-09-02T00:00:00Z", + "labels": [ + "debt" + ], + "target_id": null, + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": false, + "window_complete": true + }, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED" + } + ] + }, + { + "title": "reversed", + "observations": [ + { + "observation_id": "forge.issues.inventory", + "value_type": "series", + "value": [ + { + "number": 4, + "title": "issue 4", + "state": "CLOSED", + "created_at": "2026-06-01T00:00:00Z", + "updated_at": "2026-09-01T00:00:00Z", + "closed_at": "2026-09-02T00:00:00Z", + "labels": [ + "debt" + ], + "target_id": null, + "linkage_unresolved": [] + }, + { + "number": 3, + "title": "issue 3", + "state": "OPEN", + "created_at": "2026-06-01T00:00:00Z", + "updated_at": "2026-09-01T00:00:00Z", + "closed_at": null, + "labels": [ + "bug" + ], + "target_id": null, + "linkage_unresolved": [] + }, + { + "number": 2, + "title": "issue 2", + "state": "OPEN", + "created_at": "2026-06-01T00:00:00Z", + "updated_at": "2026-09-01T00:00:00Z", + "closed_at": null, + "labels": [ + "debt", + "ops" + ], + "target_id": null, + "linkage_unresolved": [] + }, + { + "number": 1, + "title": "issue 1", + "state": "OPEN", + "created_at": "2026-06-01T00:00:00Z", + "updated_at": "2026-09-01T00:00:00Z", + "closed_at": null, + "labels": [ + "debt" + ], + "target_id": null, + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": false, + "window_complete": true + }, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED" + } + ] + }, + { + "title": "interleaved, another cap", + "observations": [ + { + "observation_id": "forge.issues.inventory", + "value_type": "series", + "value": [ + { + "number": 3, + "title": "issue 3", + "state": "OPEN", + "created_at": "2026-06-01T00:00:00Z", + "updated_at": "2026-09-01T00:00:00Z", + "closed_at": null, + "labels": [ + "bug" + ], + "target_id": null, + "linkage_unresolved": [] + }, + { + "number": 1, + "title": "issue 1", + "state": "OPEN", + "created_at": "2026-06-01T00:00:00Z", + "updated_at": "2026-09-01T00:00:00Z", + "closed_at": null, + "labels": [ + "debt" + ], + "target_id": null, + "linkage_unresolved": [] + }, + { + "number": 4, + "title": "issue 4", + "state": "CLOSED", + "created_at": "2026-06-01T00:00:00Z", + "updated_at": "2026-09-01T00:00:00Z", + "closed_at": "2026-09-02T00:00:00Z", + "labels": [ + "debt" + ], + "target_id": null, + "linkage_unresolved": [] + }, + { + "number": 2, + "title": "issue 2", + "state": "OPEN", + "created_at": "2026-06-01T00:00:00Z", + "updated_at": "2026-09-01T00:00:00Z", + "closed_at": null, + "labels": [ + "debt", + "ops" + ], + "target_id": null, + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": false, + "window_complete": true + }, + "status": "PARTIAL", + "reason_code": "REQUEST_BUDGET_EXHAUSTED" + } + ] + } + ] + }, + "expect": { + "band": "PRESENT", + "evaluation_status": "DEGRADED", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "debt.bands.v2", + "derived": { + "open_count": 2, + "open_count_semantics": "LOWER_BOUND", + "confirmed_open_count": 2 + }, + "diagnostics": [ + "DEBT_PARTIAL_ENUMERATION", + "DEBT_CONFIRMED_OPEN_LOWER_BOUND:2", + "DEBT_BAND_FORCED_PRESENT_BY_CONFIRMED_OPEN" + ] + } + }, + { + "case": "DEBT-PARTIAL-07", + "title": "positively absent debt authority stays exactly UNINSTRUMENTED; the repair does not turn positive absence into UNKNOWN", + "kind": "vital", + "source_unit": "PV-DEBT-PARTIAL-001", + "given": { + "vital": "debt", + "observed_at": "2026-09-07T12:00:00Z", + "observations": [ + { + "observation_id": "debt.registry.capability", + "value_type": "enum", + "value": "UNCONFIGURED" + } + ] + }, + "expect": { + "band": "UNINSTRUMENTED", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "debt.bands.v2" + } + }, + { + "case": "DEBT-PARTIAL-08", + "title": "uncertain mapping authority is never inferred from a partial payload that happens to hold an apparent open debt record", + "kind": "vital", + "source_unit": "PV-DEBT-PARTIAL-001", + "given": { + "vital": "debt", + "observed_at": "2026-09-07T12:00:00Z", + "variants": [ + { + "title": "capability unknown", + "observations": [ + { + "observation_id": "debt.registry.capability", + "value_type": "enum", + "status": "UNKNOWN", + "reason_code": "NOT_OBSERVED" + }, + { + "observation_id": "debt.items.open_count", + "value": 3, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED", + "coverage": { + "complete": false, + "value_semantics": "OBSERVED_SUBSET_COUNT" + } + } + ] + }, + { + "title": "mapping unknown", + "observations": [ + { + "observation_id": "debt.registry.capability", + "value_type": "enum", + "value": "CONFIGURED" + }, + { + "observation_id": "debt.mapping", + "value_type": "record", + "status": "UNKNOWN", + "reason_code": "NOT_OBSERVED" + }, + { + "observation_id": "debt.items.open_count", + "value": 3, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED", + "coverage": { + "complete": false, + "value_semantics": "OBSERVED_SUBSET_COUNT" + } + } + ] + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "debt.bands.v2", + "diagnostics": [ + "MISSING_REQUIRED:debt." + ], + "diagnostics_absent": [ + "DEBT_BAND_FORCED_PRESENT_BY_CONFIRMED_OPEN" + ] + } + }, + { + "case": "DEBT-PARTIAL-09", + "title": "a complete, fresh positive zero remains exactly CLEAR", + "kind": "vital", + "source_unit": "PV-DEBT-PARTIAL-001", + "given": { + "vital": "debt", + "observed_at": "2026-09-07T12:00:00Z", + "observations": [ + { + "observation_id": "debt.registry.capability", + "value_type": "enum", + "value": "CONFIGURED" + }, + { + "observation_id": "debt.mapping", + "value_type": "record", + "value": { + "source": "labels", + "labels": [ + "debt" + ], + "mapping_version": "1" + } + }, + { + "observation_id": "debt.items.open_count", + "value": 0 + } + ] + }, + "expect": { + "band": "CLEAR", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "debt.bands.v2", + "derived": { + "open_count": 0 + } + } + }, + { + "case": "DEBT-PARTIAL-10", + "title": "a complete, fresh positive open count remains exactly PRESENT", + "kind": "vital", + "source_unit": "PV-DEBT-PARTIAL-001", + "given": { + "vital": "debt", + "observed_at": "2026-09-07T12:00:00Z", + "observations": [ + { + "observation_id": "debt.registry.capability", + "value_type": "enum", + "value": "CONFIGURED" + }, + { + "observation_id": "debt.mapping", + "value_type": "record", + "value": { + "source": "labels", + "labels": [ + "debt" + ], + "mapping_version": "1" + } + }, + { + "observation_id": "debt.items.open_count", + "value": 4 + } + ] + }, + "expect": { + "band": "PRESENT", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "debt.bands.v2", + "derived": { + "open_count": 4 + } + } + }, + { + "case": "DEBT-PARTIAL-11", + "title": "a partial zero stays UNKNOWN beside one hundred closed items: closed history cannot prove current open emptiness", + "kind": "vital", + "source_unit": "PV-DEBT-PARTIAL-001", + "given": { + "vital": "debt", + "observed_at": "2026-09-07T12:00:00Z", + "observations": [ + { + "observation_id": "debt.registry.capability", + "value_type": "enum", + "value": "CONFIGURED" + }, + { + "observation_id": "debt.mapping", + "value_type": "record", + "value": { + "source": "labels", + "labels": [ + "debt" + ], + "mapping_version": "1" + } + }, + { + "observation_id": "debt.items.open_count", + "value": 0, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED", + "coverage": { + "complete": false, + "value_semantics": "OBSERVED_SUBSET_COUNT" + } + }, + { + "observation_id": "debt.items.closed_count_28d", + "value": 100 + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "debt.bands.v2", + "derived": { + "open_count": 0, + "open_count_semantics": "LOWER_BOUND", + "confirmed_open_count": 0, + "closed_count_28d": 100 + }, + "diagnostics": [ + "DEBT_PARTIAL_ENUMERATION", + "DEBT_CONFIRMED_OPEN_LOWER_BOUND:0" + ], + "diagnostics_absent": [ + "DEBT_BAND_FORCED_PRESENT_BY_CONFIRMED_OPEN" + ] + } + }, + { + "case": "DEBT-PARTIAL-12", + "title": "a forced PRESENT under partial acquisition stays visibly DEGRADED, never AVAILABLE, with the acquisition machine-visible", + "kind": "vital", + "source_unit": "PV-DEBT-PARTIAL-001", + "given": { + "vital": "debt", + "observed_at": "2026-09-07T12:00:00Z", + "observations": [ + { + "observation_id": "debt.registry.capability", + "value_type": "enum", + "value": "CONFIGURED" + }, + { + "observation_id": "debt.mapping", + "value_type": "record", + "value": { + "source": "labels", + "labels": [ + "debt" + ], + "mapping_version": "1" + } + }, + { + "observation_id": "forge.issues.inventory", + "value_type": "series", + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED", + "value": [ + { + "number": 9, + "title": "issue 9", + "state": "OPEN", + "created_at": "2026-06-01T00:00:00Z", + "updated_at": "2026-09-01T00:00:00Z", + "closed_at": null, + "labels": [ + "debt" + ], + "target_id": null, + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": false, + "window_complete": true + } + }, + { + "observation_id": "debt.items.open_count", + "value": 1, + "status": "PARTIAL", + "reason_code": "SOURCE_COVERAGE_INCOMPLETE", + "coverage": { + "complete": false, + "value_semantics": "OBSERVED_SUBSET_COUNT" + }, + "evidence_ref": { + "derived_from": [ + "forge.issues.inventory", + "debt.mapping" + ] + } + } + ] + }, + "expect": { + "band": "PRESENT", + "evaluation_status": "DEGRADED", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "debt.bands.v2", + "derived": { + "open_count": 1, + "open_count_semantics": "LOWER_BOUND", + "confirmed_open_count": 1, + "partial_enumeration": { + "status": "PARTIAL", + "reason_code": "SOURCE_COVERAGE_INCOMPLETE", + "source": "forge.issues.inventory", + "source_reason_code": "PAGINATION_CAPPED" + } + }, + "diagnostics": [ + "DEBT_PARTIAL_ENUMERATION", + "DEBT_CONFIRMED_OPEN_LOWER_BOUND:1", + "DEBT_BAND_FORCED_PRESENT_BY_CONFIRMED_OPEN" + ] + } + }, + { + "case": "DEBT-PARTIAL-13", + "title": "a stale positive count is not a confirmed current open item: UNKNOWN, as before the repair", + "kind": "vital", + "source_unit": "PV-DEBT-PARTIAL-001", + "given": { + "vital": "debt", + "observed_at": "2026-09-07T12:00:00Z", + "variants": [ + { + "title": "partial and stale", + "observations": [ + { + "observation_id": "debt.registry.capability", + "value_type": "enum", + "value": "CONFIGURED" + }, + { + "observation_id": "debt.mapping", + "value_type": "record", + "value": { + "source": "labels", + "labels": [ + "debt" + ], + "mapping_version": "1" + } + }, + { + "observation_id": "debt.items.open_count", + "value": 5, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED", + "coverage": { + "complete": false, + "value_semantics": "OBSERVED_SUBSET_COUNT" + }, + "freshness": "STALE" + } + ] + }, + { + "title": "complete and stale", + "observations": [ + { + "observation_id": "debt.registry.capability", + "value_type": "enum", + "value": "CONFIGURED" + }, + { + "observation_id": "debt.mapping", + "value_type": "record", + "value": { + "source": "labels", + "labels": [ + "debt" + ], + "mapping_version": "1" + } + }, + { + "observation_id": "debt.items.open_count", + "value": 5, + "freshness": "STALE" + } + ] + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "debt.bands.v2", + "diagnostics": [ + "MISSING_REQUIRED:debt.items.open_count" + ], + "diagnostics_absent": [ + "DEBT_BAND_FORCED_PRESENT_BY_CONFIRMED_OPEN" + ] + } + }, + { + "case": "DEBT-PARTIAL-14", + "title": "one confirmed current open item survives other returned or tail records that stay unresolved under the partial receipt", + "kind": "vital", + "source_unit": "PV-DEBT-PARTIAL-001", + "given": { + "vital": "debt", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "none", + "debt_mapping": { + "source": "labels", + "labels": [ + "debt" + ], + "mapping_version": "1" + } + }, + "observations": [ + { + "observation_id": "forge.issues.inventory", + "value_type": "series", + "status": "PARTIAL", + "reason_code": "REQUEST_BUDGET_EXHAUSTED", + "value": [ + { + "number": 11, + "title": "issue 11", + "state": "OPEN", + "created_at": "2026-06-01T00:00:00Z", + "updated_at": "2026-09-01T00:00:00Z", + "closed_at": null, + "labels": [ + "debt" + ], + "target_id": null, + "linkage_unresolved": [] + }, + { + "number": 12, + "title": "issue 12", + "state": "OPEN", + "created_at": "2026-06-01T00:00:00Z", + "updated_at": "2026-09-01T00:00:00Z", + "closed_at": null, + "labels": [], + "target_id": null, + "linkage_unresolved": [] + }, + { + "number": 13, + "title": "issue 13", + "state": "CLOSED", + "created_at": "2026-06-01T00:00:00Z", + "updated_at": "2026-09-01T00:00:00Z", + "closed_at": "2026-09-01T00:00:00Z", + "labels": [ + "debt" + ], + "target_id": null, + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": false, + "window_complete": false + } + } + ] + }, + "expect": { + "band": "PRESENT", + "evaluation_status": "DEGRADED", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "debt.bands.v2", + "derived": { + "open_count": 1, + "open_count_semantics": "LOWER_BOUND", + "confirmed_open_count": 1 + }, + "diagnostics": [ + "DEBT_PARTIAL_ENUMERATION", + "DEBT_CONFIRMED_OPEN_LOWER_BOUND:1", + "DEBT_BAND_FORCED_PRESENT_BY_CONFIRMED_OPEN" + ] + } + }, + { + "case": "DEBT-PARTIAL-15", + "title": "a versioned project-local quantitative policy does not replace the canonical core under partial positive evidence", + "kind": "vital", + "source_unit": "PV-DEBT-PARTIAL-001", + "given": { + "vital": "debt", + "observed_at": "2026-09-07T12:00:00Z", + "observations": [ + { + "observation_id": "debt.registry.capability", + "value_type": "enum", + "value": "CONFIGURED" + }, + { + "observation_id": "debt.mapping", + "value_type": "record", + "value": { + "source": "labels", + "labels": [ + "debt" + ], + "mapping_version": "1", + "local_policy": { + "policy_version": "acme-debt-1", + "accumulated_open_count": 5 + } + } + }, + { + "observation_id": "debt.items.open_count", + "value": 12, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED", + "coverage": { + "complete": false, + "value_semantics": "OBSERVED_SUBSET_COUNT" + } + } + ] + }, + "expect": { + "band": "PRESENT", + "evaluation_status": "DEGRADED", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "debt.bands.v2", + "derived": { + "open_count": 12, + "open_count_semantics": "LOWER_BOUND", + "confirmed_open_count": 12, + "mapping": { + "source": "labels", + "labels": [ + "debt" + ], + "mapping_version": "1", + "local_policy": { + "policy_version": "acme-debt-1", + "accumulated_open_count": 5 + } + } + }, + "diagnostics": [ + "DEBT_PARTIAL_ENUMERATION", + "DEBT_CONFIRMED_OPEN_LOWER_BOUND:12", + "DEBT_BAND_FORCED_PRESENT_BY_CONFIRMED_OPEN" + ] + } + }, + { + "case": "DEBT-PARTIAL-16", + "title": "a project-local policy cannot turn a partial zero into any canonical conclusion", + "kind": "vital", + "source_unit": "PV-DEBT-PARTIAL-001", + "given": { + "vital": "debt", + "observed_at": "2026-09-07T12:00:00Z", + "observations": [ + { + "observation_id": "debt.registry.capability", + "value_type": "enum", + "value": "CONFIGURED" + }, + { + "observation_id": "debt.mapping", + "value_type": "record", + "value": { + "source": "labels", + "labels": [ + "debt" + ], + "mapping_version": "1", + "local_policy": { + "policy_version": "acme-debt-1", + "accumulated_open_count": 5 + } + } + }, + { + "observation_id": "debt.items.open_count", + "value": 0, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED", + "coverage": { + "complete": false, + "value_semantics": "OBSERVED_SUBSET_COUNT" + } + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "debt.bands.v2", + "derived": { + "open_count": 0, + "open_count_semantics": "LOWER_BOUND", + "confirmed_open_count": 0, + "mapping": { + "source": "labels", + "labels": [ + "debt" + ], + "mapping_version": "1", + "local_policy": { + "policy_version": "acme-debt-1", + "accumulated_open_count": 5 + } + } + }, + "diagnostics": [ + "DEBT_PARTIAL_ENUMERATION", + "DEBT_CONFIRMED_OPEN_LOWER_BOUND:0" + ], + "diagnostics_absent": [ + "DEBT_BAND_FORCED_PRESENT_BY_CONFIRMED_OPEN" + ] + } + } + ] +} diff --git a/tests/vectors/direction.json b/tests/vectors/direction.json new file mode 100644 index 0000000..ece86b3 --- /dev/null +++ b/tests/vectors/direction.json @@ -0,0 +1,3188 @@ +{ + "schema": "devostasis.vectors.v1", + "notes": "Cases DIR-CLOSED-01..09 of PV-REV-DIRECTION-CLOSED-TARGET-001 and DIR-INCOMPLETE-01..16 of PV-DIRECTION-INCOMPLETE-001 (accepted by PV-REV-DIRECTION-INCOMPLETE-001), transcribed from the contracts' required fixtures and adopted in 0.2.0 as one Direction rule version, direction.bands.v2. Cases about per-change evidence state the change-request and target inventories and run the derivation (given.derive), so the state-neutral linkage and the LINKED/UNLINKED/UNRESOLVED reduction are executed where they happen; cases about the completion rule itself state the N/L/U/R counts. A reference's state is what its resolution established: OPEN or CLOSED when the declared target resolves, MISSING when a complete register positively lacks the id, anything else unresolved.", + "vectors": [ + { + "case": "DIR-CLOSED-01", + "title": "an active change request with an explicit link to an open target is linked", + "kind": "vital", + "source_unit": "PV-REV-DIRECTION-CLOSED-TARGET-001", + "given": { + "vital": "direction", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "OPEN", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "milestones" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "1", + "target_state": "OPEN", + "target_refs": [ + { + "target_id": "1", + "state": "OPEN" + } + ], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + }, + "expect": { + "band": "FULLY_LINKED", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "direction.bands.v2", + "derived": { + "active_change_count_28d": 1, + "linked_active_change_count_28d": 1, + "unlinked_active_change_count_28d": 0, + "unresolved_active_change_count_28d": 0 + } + } + }, + { + "case": "DIR-CLOSED-02", + "title": "the same change request and target id stay linked when the target moves from open to closed; the linkage share is unchanged", + "kind": "vital", + "source_unit": "PV-REV-DIRECTION-CLOSED-TARGET-001", + "given": { + "vital": "direction", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "variants": [ + { + "title": "target 1 open", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "OPEN", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "milestones" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "1", + "target_state": "OPEN", + "target_refs": [ + { + "target_id": "1", + "state": "OPEN" + } + ], + "linkage_unresolved": [] + }, + { + "number": 2, + "title": "change 2", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": null, + "target_state": null, + "target_refs": [], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + }, + { + "title": "target 1 closed", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "CLOSED", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "milestones" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "1", + "target_state": "CLOSED", + "target_refs": [ + { + "target_id": "1", + "state": "CLOSED" + } + ], + "linkage_unresolved": [] + }, + { + "number": 2, + "title": "change 2", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": null, + "target_state": null, + "target_refs": [], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + } + ] + }, + "expect": { + "band": "MIXED", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "direction.bands.v2", + "derived": { + "active_change_count_28d": 2, + "linked_active_change_count_28d": 1, + "unlinked_active_change_count_28d": 1, + "unresolved_active_change_count_28d": 0 + } + } + }, + { + "case": "DIR-CLOSED-03", + "title": "a link to a closed target is linked when the target identity resolves authoritatively", + "kind": "vital", + "source_unit": "PV-REV-DIRECTION-CLOSED-TARGET-001", + "given": { + "vital": "direction", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "variants": [ + { + "title": "milestone embedded in the change request", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "4", + "title": "target 4", + "state": "CLOSED", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "milestones" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "4", + "target_state": "CLOSED", + "target_refs": [ + { + "target_id": "4", + "state": "CLOSED" + } + ], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + }, + { + "title": "register-backed target", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "T-4", + "title": "target T-4", + "state": "CLOSED", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "file" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-4", + "target_state": "CLOSED", + "target_refs": [ + { + "target_id": "T-4", + "state": "CLOSED" + } + ], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + } + ] + }, + "expect": { + "band": "FULLY_LINKED", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "direction.bands.v2", + "derived": { + "active_change_count_28d": 1, + "linked_active_change_count_28d": 1, + "unlinked_active_change_count_28d": 0, + "unresolved_active_change_count_28d": 0 + } + } + }, + { + "case": "DIR-CLOSED-04", + "title": "a reference to a target the complete register positively lacks is unlinked with the missing-reference diagnostic; linkage is never fabricated", + "kind": "vital", + "source_unit": "PV-REV-DIRECTION-CLOSED-TARGET-001", + "given": { + "vital": "direction", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "file", + "debt_mapping": null + }, + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "T-1", + "title": "target T-1", + "state": "OPEN", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "file" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-9", + "target_state": "MISSING", + "target_refs": [ + { + "target_id": "T-9", + "state": "MISSING" + } + ], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + }, + "expect": { + "band": "SCATTERED", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "direction.bands.v2", + "derived": { + "active_change_count_28d": 1, + "linked_active_change_count_28d": 0, + "unlinked_active_change_count_28d": 1, + "unresolved_active_change_count_28d": 0, + "missing_target_reference_count_28d": 1 + }, + "diagnostics": [ + "DIRECTION_TARGET_REFERENCE_MISSING" + ] + }, + "notes": "The judgement's 'unlinked or unresolved' was narrowed by PV-REV-DIRECTION-INCOMPLETE-001: a positively missing id is UNLINKED (this case, DIR-INCOMPLETE-07); an id nobody could resolve is UNRESOLVED (DIR-INCOMPLETE-08, -09). Neither is ever linked." + }, + { + "case": "DIR-CLOSED-05", + "title": "closing a target cannot by itself worsen the Direction band", + "kind": "vital", + "source_unit": "PV-REV-DIRECTION-CLOSED-TARGET-001", + "given": { + "vital": "direction", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "variants": [ + { + "title": "before: target 1 open", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "OPEN", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "milestones" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "1", + "target_state": "OPEN", + "target_refs": [ + { + "target_id": "1", + "state": "OPEN" + } + ], + "linkage_unresolved": [] + }, + { + "number": 2, + "title": "change 2", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "1", + "target_state": "OPEN", + "target_refs": [ + { + "target_id": "1", + "state": "OPEN" + } + ], + "linkage_unresolved": [] + }, + { + "number": 3, + "title": "change 3", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": null, + "target_state": null, + "target_refs": [], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + }, + { + "title": "after: target 1 closed", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "CLOSED", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "milestones" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "1", + "target_state": "CLOSED", + "target_refs": [ + { + "target_id": "1", + "state": "CLOSED" + } + ], + "linkage_unresolved": [] + }, + { + "number": 2, + "title": "change 2", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "1", + "target_state": "CLOSED", + "target_refs": [ + { + "target_id": "1", + "state": "CLOSED" + } + ], + "linkage_unresolved": [] + }, + { + "number": 3, + "title": "change 3", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": null, + "target_state": null, + "target_refs": [], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + } + ] + }, + "expect": { + "band": "MIXED", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "direction.bands.v2", + "derived": { + "active_change_count_28d": 3, + "linked_active_change_count_28d": 2, + "unlinked_active_change_count_28d": 1, + "unresolved_active_change_count_28d": 0 + } + }, + "notes": "Under direction.bands.v1.1 the closed variant counted 0 of 3 linked and was SCATTERED: calibration finding 9." + }, + { + "case": "DIR-CLOSED-06", + "title": "reopening a target cannot by itself improve the Direction band", + "kind": "vital", + "source_unit": "PV-REV-DIRECTION-CLOSED-TARGET-001", + "given": { + "vital": "direction", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "variants": [ + { + "title": "before: target 2 closed", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "2", + "title": "target 2", + "state": "CLOSED", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "milestones" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "2", + "target_state": "CLOSED", + "target_refs": [ + { + "target_id": "2", + "state": "CLOSED" + } + ], + "linkage_unresolved": [] + }, + { + "number": 2, + "title": "change 2", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": null, + "target_state": null, + "target_refs": [], + "linkage_unresolved": [] + }, + { + "number": 3, + "title": "change 3", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": null, + "target_state": null, + "target_refs": [], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + }, + { + "title": "after: target 2 reopened", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "2", + "title": "target 2", + "state": "OPEN", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "milestones" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "2", + "target_state": "OPEN", + "target_refs": [ + { + "target_id": "2", + "state": "OPEN" + } + ], + "linkage_unresolved": [] + }, + { + "number": 2, + "title": "change 2", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": null, + "target_state": null, + "target_refs": [], + "linkage_unresolved": [] + }, + { + "number": 3, + "title": "change 3", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": null, + "target_state": null, + "target_refs": [], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + } + ] + }, + "expect": { + "band": "SCATTERED", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "direction.bands.v2", + "derived": { + "active_change_count_28d": 3, + "linked_active_change_count_28d": 1, + "unlinked_active_change_count_28d": 2, + "unresolved_active_change_count_28d": 0 + } + } + }, + { + "case": "DIR-CLOSED-07", + "title": "Horizon's open-target counts still exclude the closed target a change request stays linked to, so the two Vitals may diverge", + "kind": "vital", + "source_unit": "PV-REV-DIRECTION-CLOSED-TARGET-001", + "given": { + "vital": "horizon", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "CLOSED", + "due_at": "2026-12-01T00:00:00Z" + } + ], + "coverage": { + "complete": true, + "source": "milestones" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "1", + "target_state": "CLOSED", + "target_refs": [ + { + "target_id": "1", + "state": "CLOSED" + } + ], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + }, + "expect": { + "band": "UNDECLARED", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "horizon.bands.v2", + "derived": { + "capability": "SUPPORTED", + "open_count": 0, + "open_with_future_boundary_count": 0, + "open_beyond_28d_count": 0 + } + }, + "notes": "The same evidence is FULLY_LINKED for Direction (DIR-CLOSED-03): Direction traces work to declared targets, Horizon counts what is still open." + }, + { + "case": "DIR-CLOSED-08", + "title": "every active change request linked to one closed target is FULLY_LINKED with the single-target diagnostic: traceability, not alignment", + "kind": "vital", + "source_unit": "PV-REV-DIRECTION-CLOSED-TARGET-001", + "given": { + "vital": "direction", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "5", + "title": "target 5", + "state": "CLOSED", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "milestones" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "5", + "target_state": "CLOSED", + "target_refs": [ + { + "target_id": "5", + "state": "CLOSED" + } + ], + "linkage_unresolved": [] + }, + { + "number": 2, + "title": "change 2", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "5", + "target_state": "CLOSED", + "target_refs": [ + { + "target_id": "5", + "state": "CLOSED" + } + ], + "linkage_unresolved": [] + }, + { + "number": 3, + "title": "change 3", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "5", + "target_state": "CLOSED", + "target_refs": [ + { + "target_id": "5", + "state": "CLOSED" + } + ], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + }, + "expect": { + "band": "FULLY_LINKED", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "direction.bands.v2", + "derived": { + "active_change_count_28d": 3, + "linked_active_change_count_28d": 3, + "unlinked_active_change_count_28d": 0, + "unresolved_active_change_count_28d": 0, + "links_per_target": { + "5": 3 + } + }, + "diagnostics": [ + "ALL_LINKS_TO_SINGLE_TARGET" + ] + } + }, + { + "case": "DIR-CLOSED-09", + "title": "adopting the repair changes the Direction rule id: the first comparison across it is INCOMPARABLE for Direction alone, while an unaffected Vital still compares", + "kind": "delta", + "source_unit": "PV-REV-DIRECTION-CLOSED-TARGET-001", + "given": { + "previous": { + "vitals": [ + { + "vital_id": "direction", + "band": "SCATTERED", + "rule_id": "direction.bands.v1.1" + }, + { + "vital_id": "pulse", + "band": "STEADY", + "rule_id": "pulse.bands.v1" + } + ] + }, + "current": { + "vitals": [ + { + "vital_id": "direction", + "band": "MIXED", + "rule_id": "direction.bands.v2" + }, + { + "vital_id": "pulse", + "band": "STEADY", + "rule_id": "pulse.bands.v1" + } + ] + } + }, + "expect": { + "comparison_status": "COMPARABLE", + "vitals": { + "direction": { + "transition_class": "INCOMPARABLE", + "reason_codes": [ + "RULE_VERSION_BOUNDARY:direction.bands.v1.1->direction.bands.v2" + ], + "metric_deltas": {} + }, + "pulse": { + "transition_class": "UNCHANGED" + } + } + } + }, + { + "case": "DIR-INCOMPLETE-01", + "title": "N=5, L=0, U=3, R=2: every completion k in {0,1,2} is SCATTERED", + "kind": "vital", + "source_unit": "PV-DIRECTION-INCOMPLETE-001", + "given": { + "vital": "direction", + "observed_at": "2026-09-07T12:00:00Z", + "observations": [ + { + "observation_id": "planning.explicit_targets.capability", + "value_type": "enum", + "value": "SUPPORTED" + }, + { + "observation_id": "planning.linkage.active_change_requests_count_28d", + "value": 5 + }, + { + "observation_id": "planning.linkage.active_change_requests_linked_count_28d", + "value": 0 + }, + { + "observation_id": "planning.linkage.active_change_requests_unlinked_count_28d", + "value": 3 + }, + { + "observation_id": "planning.linkage.active_change_requests_unresolved_count_28d", + "value": 2 + } + ] + }, + "expect": { + "band": "SCATTERED", + "evaluation_status": "DEGRADED", + "band_semantics": "EXACT", + "possible_bands": [ + "SCATTERED" + ], + "rule_id": "direction.bands.v2", + "derived": { + "active_change_count_28d": 5, + "linked_active_change_count_28d": 0, + "unlinked_active_change_count_28d": 3, + "unresolved_active_change_count_28d": 2, + "possible_bands_semantics": "NON_AUTHORITATIVE_CONSERVATIVE_SUPERSET" + }, + "diagnostics": [ + "DIRECTION_INCOMPLETE_BAND_INVARIANT" + ], + "diagnostics_absent": [ + "DIRECTION_INCOMPLETE_AMBIGUOUS" + ] + } + }, + { + "case": "DIR-INCOMPLETE-02", + "title": "N=5, L=3, U=1, R=1: every completion k in {3,4} is MIXED", + "kind": "vital", + "source_unit": "PV-DIRECTION-INCOMPLETE-001", + "given": { + "vital": "direction", + "observed_at": "2026-09-07T12:00:00Z", + "observations": [ + { + "observation_id": "planning.explicit_targets.capability", + "value_type": "enum", + "value": "SUPPORTED" + }, + { + "observation_id": "planning.linkage.active_change_requests_count_28d", + "value": 5 + }, + { + "observation_id": "planning.linkage.active_change_requests_linked_count_28d", + "value": 3 + }, + { + "observation_id": "planning.linkage.active_change_requests_unlinked_count_28d", + "value": 1 + }, + { + "observation_id": "planning.linkage.active_change_requests_unresolved_count_28d", + "value": 1 + } + ] + }, + "expect": { + "band": "MIXED", + "evaluation_status": "DEGRADED", + "band_semantics": "EXACT", + "possible_bands": [ + "MIXED" + ], + "rule_id": "direction.bands.v2", + "derived": { + "active_change_count_28d": 5, + "linked_active_change_count_28d": 3, + "unlinked_active_change_count_28d": 1, + "unresolved_active_change_count_28d": 1, + "possible_bands_semantics": "NON_AUTHORITATIVE_CONSERVATIVE_SUPERSET" + }, + "diagnostics": [ + "DIRECTION_INCOMPLETE_BAND_INVARIANT" + ], + "diagnostics_absent": [ + "DIRECTION_INCOMPLETE_AMBIGUOUS" + ] + } + }, + { + "case": "DIR-INCOMPLETE-03", + "title": "N=4, L=2, U=0, R=2: completions reach MIXED and FULLY_LINKED, so no band", + "kind": "vital", + "source_unit": "PV-DIRECTION-INCOMPLETE-001", + "given": { + "vital": "direction", + "observed_at": "2026-09-07T12:00:00Z", + "observations": [ + { + "observation_id": "planning.explicit_targets.capability", + "value_type": "enum", + "value": "SUPPORTED" + }, + { + "observation_id": "planning.linkage.active_change_requests_count_28d", + "value": 4 + }, + { + "observation_id": "planning.linkage.active_change_requests_linked_count_28d", + "value": 2 + }, + { + "observation_id": "planning.linkage.active_change_requests_unlinked_count_28d", + "value": 0 + }, + { + "observation_id": "planning.linkage.active_change_requests_unresolved_count_28d", + "value": 2 + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "direction.bands.v2", + "derived": { + "active_change_count_28d": 4, + "linked_active_change_count_28d": 2, + "unlinked_active_change_count_28d": 0, + "unresolved_active_change_count_28d": 2, + "reachable_bands": [ + "MIXED", + "FULLY_LINKED" + ] + }, + "diagnostics": [ + "DIRECTION_INCOMPLETE_AMBIGUOUS" + ], + "diagnostics_absent": [ + "DIRECTION_INCOMPLETE_BAND_INVARIANT" + ] + } + }, + { + "case": "DIR-INCOMPLETE-04", + "title": "N=5, L=1, U=1, R=3: completions reach SCATTERED and MIXED, so no band", + "kind": "vital", + "source_unit": "PV-DIRECTION-INCOMPLETE-001", + "given": { + "vital": "direction", + "observed_at": "2026-09-07T12:00:00Z", + "observations": [ + { + "observation_id": "planning.explicit_targets.capability", + "value_type": "enum", + "value": "SUPPORTED" + }, + { + "observation_id": "planning.linkage.active_change_requests_count_28d", + "value": 5 + }, + { + "observation_id": "planning.linkage.active_change_requests_linked_count_28d", + "value": 1 + }, + { + "observation_id": "planning.linkage.active_change_requests_unlinked_count_28d", + "value": 1 + }, + { + "observation_id": "planning.linkage.active_change_requests_unresolved_count_28d", + "value": 3 + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "direction.bands.v2", + "derived": { + "active_change_count_28d": 5, + "linked_active_change_count_28d": 1, + "unlinked_active_change_count_28d": 1, + "unresolved_active_change_count_28d": 3, + "reachable_bands": [ + "SCATTERED", + "MIXED" + ] + }, + "diagnostics": [ + "DIRECTION_INCOMPLETE_AMBIGUOUS" + ], + "diagnostics_absent": [ + "DIRECTION_INCOMPLETE_BAND_INVARIANT" + ] + } + }, + { + "case": "DIR-INCOMPLETE-05", + "title": "N=1, L=0, U=0, R=1: completions reach SCATTERED and FULLY_LINKED; never DEGRADED FULLY_LINKED", + "kind": "vital", + "source_unit": "PV-DIRECTION-INCOMPLETE-001", + "given": { + "vital": "direction", + "observed_at": "2026-09-07T12:00:00Z", + "observations": [ + { + "observation_id": "planning.explicit_targets.capability", + "value_type": "enum", + "value": "SUPPORTED" + }, + { + "observation_id": "planning.linkage.active_change_requests_count_28d", + "value": 1 + }, + { + "observation_id": "planning.linkage.active_change_requests_linked_count_28d", + "value": 0 + }, + { + "observation_id": "planning.linkage.active_change_requests_unlinked_count_28d", + "value": 0 + }, + { + "observation_id": "planning.linkage.active_change_requests_unresolved_count_28d", + "value": 1 + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "direction.bands.v2", + "derived": { + "active_change_count_28d": 1, + "linked_active_change_count_28d": 0, + "unlinked_active_change_count_28d": 0, + "unresolved_active_change_count_28d": 1, + "reachable_bands": [ + "SCATTERED", + "FULLY_LINKED" + ] + }, + "diagnostics": [ + "DIRECTION_INCOMPLETE_AMBIGUOUS" + ], + "diagnostics_absent": [ + "DIRECTION_INCOMPLETE_BAND_INVARIANT" + ] + } + }, + { + "case": "DIR-INCOMPLETE-06", + "title": "a marker to an authoritatively resolved closed target is LINKED; closing or reopening the target alone does not change the linkage", + "kind": "vital", + "source_unit": "PV-DIRECTION-INCOMPLETE-001", + "given": { + "vital": "direction", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "file", + "debt_mapping": null + }, + "variants": [ + { + "title": "target T-2 closed", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "T-2", + "title": "target T-2", + "state": "CLOSED", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "file" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-2", + "target_state": "CLOSED", + "target_refs": [ + { + "target_id": "T-2", + "state": "CLOSED" + } + ], + "linkage_unresolved": [] + }, + { + "number": 2, + "title": "change 2", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-2", + "target_state": "CLOSED", + "target_refs": [ + { + "target_id": "T-2", + "state": "CLOSED" + } + ], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + }, + { + "title": "target T-2 reopened", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "T-2", + "title": "target T-2", + "state": "OPEN", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "file" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-2", + "target_state": "OPEN", + "target_refs": [ + { + "target_id": "T-2", + "state": "OPEN" + } + ], + "linkage_unresolved": [] + }, + { + "number": 2, + "title": "change 2", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-2", + "target_state": "OPEN", + "target_refs": [ + { + "target_id": "T-2", + "state": "OPEN" + } + ], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + } + ] + }, + "expect": { + "band": "FULLY_LINKED", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "direction.bands.v2", + "derived": { + "active_change_count_28d": 2, + "linked_active_change_count_28d": 2, + "unlinked_active_change_count_28d": 0, + "unresolved_active_change_count_28d": 0 + } + } + }, + { + "case": "DIR-INCOMPLETE-07", + "title": "a marker to T that a complete authoritative lookup proves absent is UNLINKED: N=1, L=0, U=1, R=0 is exactly SCATTERED with the missing-reference diagnostic", + "kind": "vital", + "source_unit": "PV-DIRECTION-INCOMPLETE-001", + "given": { + "vital": "direction", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "file", + "debt_mapping": null + }, + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "T-1", + "title": "target T-1", + "state": "OPEN", + "due_at": null + }, + { + "target_id": "T-2", + "title": "target T-2", + "state": "CLOSED", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "file" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T", + "target_state": "MISSING", + "target_refs": [ + { + "target_id": "T", + "state": "MISSING" + } + ], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + }, + "expect": { + "band": "SCATTERED", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "direction.bands.v2", + "derived": { + "active_change_count_28d": 1, + "linked_active_change_count_28d": 0, + "unlinked_active_change_count_28d": 1, + "unresolved_active_change_count_28d": 0, + "missing_target_reference_count_28d": 1 + }, + "diagnostics": [ + "DIRECTION_TARGET_REFERENCE_MISSING" + ] + } + }, + { + "case": "DIR-INCOMPLETE-08", + "title": "a marker to T while the target list is PARTIAL and T is not in the observed subset, with no direct lookup, is UNRESOLVED: no band", + "kind": "vital", + "source_unit": "PV-DIRECTION-INCOMPLETE-001", + "given": { + "vital": "direction", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "OPEN", + "due_at": null + }, + { + "target_id": "2", + "title": "target 2", + "state": "CLOSED", + "due_at": null + } + ], + "coverage": { + "complete": false, + "source": "milestones" + }, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED" + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T", + "target_state": "UNKNOWN", + "target_refs": [ + { + "target_id": "T", + "state": "UNKNOWN" + } + ], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "direction.bands.v2", + "derived": { + "active_change_count_28d": 1, + "linked_active_change_count_28d": 0, + "unlinked_active_change_count_28d": 0, + "unresolved_active_change_count_28d": 1, + "unresolved_change_requests": { + "1": [ + "TARGET_UNRESOLVED:T:UNKNOWN" + ] + }, + "reachable_bands": [ + "SCATTERED", + "FULLY_LINKED" + ] + }, + "diagnostics": [ + "DIRECTION_INCOMPLETE_AMBIGUOUS" + ], + "diagnostics_absent": [ + "DIRECTION_INCOMPLETE_BAND_INVARIANT" + ] + }, + "notes": "The incomplete target list still proves the planning capability (a returned target exists), so Direction reaches the completion rule; absence from the partial list is never deletion proof." + }, + { + "case": "DIR-INCOMPLETE-09", + "title": "a marker whose target resolution is unavailable, forbidden, unknown, errored or stale is UNRESOLVED, never LINKED by fallback", + "kind": "vital", + "source_unit": "PV-DIRECTION-INCOMPLETE-001", + "given": { + "vital": "direction", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "file", + "debt_mapping": null + }, + "variants": [ + { + "title": "resolution UNAVAILABLE", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "T-1", + "title": "target T-1", + "state": "OPEN", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "file" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-1", + "target_state": "UNAVAILABLE", + "target_refs": [ + { + "target_id": "T-1", + "state": "UNAVAILABLE" + } + ], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + }, + { + "title": "resolution FORBIDDEN", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "T-1", + "title": "target T-1", + "state": "OPEN", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "file" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-1", + "target_state": "FORBIDDEN", + "target_refs": [ + { + "target_id": "T-1", + "state": "FORBIDDEN" + } + ], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + }, + { + "title": "resolution UNKNOWN", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "T-1", + "title": "target T-1", + "state": "OPEN", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "file" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-1", + "target_state": "UNKNOWN", + "target_refs": [ + { + "target_id": "T-1", + "state": "UNKNOWN" + } + ], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + }, + { + "title": "resolution ERROR", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "T-1", + "title": "target T-1", + "state": "OPEN", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "file" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-1", + "target_state": "ERROR", + "target_refs": [ + { + "target_id": "T-1", + "state": "ERROR" + } + ], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + }, + { + "title": "resolution STALE", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "T-1", + "title": "target T-1", + "state": "OPEN", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "file" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-1", + "target_state": "STALE", + "target_refs": [ + { + "target_id": "T-1", + "state": "STALE" + } + ], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + }, + { + "title": "link evidence unreadable", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "T-1", + "title": "target T-1", + "state": "OPEN", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "file" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": null, + "target_state": null, + "target_refs": [], + "linkage_unresolved": [ + "body:dict" + ] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "direction.bands.v2", + "derived": { + "active_change_count_28d": 1, + "linked_active_change_count_28d": 0, + "unlinked_active_change_count_28d": 0, + "unresolved_active_change_count_28d": 1, + "reachable_bands": [ + "SCATTERED", + "FULLY_LINKED" + ] + }, + "diagnostics": [ + "DIRECTION_INCOMPLETE_AMBIGUOUS" + ], + "diagnostics_absent": [ + "DIRECTION_INCOMPLETE_BAND_INVARIANT" + ] + } + }, + { + "case": "DIR-INCOMPLETE-10", + "title": "a PARTIAL global target enumeration does not degrade Direction when every referenced target is resolved on its own", + "kind": "vital", + "source_unit": "PV-DIRECTION-INCOMPLETE-001", + "given": { + "vital": "direction", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "OPEN", + "due_at": null + } + ], + "coverage": { + "complete": false, + "source": "milestones" + }, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED" + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "7", + "target_state": "OPEN", + "target_refs": [ + { + "target_id": "7", + "state": "OPEN" + } + ], + "linkage_unresolved": [] + }, + { + "number": 2, + "title": "change 2", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "3", + "target_state": "CLOSED", + "target_refs": [ + { + "target_id": "3", + "state": "CLOSED" + } + ], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + }, + "expect": { + "band": "FULLY_LINKED", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "direction.bands.v2", + "derived": { + "active_change_count_28d": 2, + "linked_active_change_count_28d": 2, + "unlinked_active_change_count_28d": 0, + "unresolved_active_change_count_28d": 0 + } + }, + "notes": "Targets 7 and 3 are resolved by the milestones embedded in the change requests, not by the capped target list; Horizon over the same evidence stays incomplete (HOR-PARTIAL-08)." + }, + { + "case": "DIR-INCOMPLETE-11", + "title": "partial link evidence leaves two change requests unresolved, but N/L/U/R forces SCATTERED for every completion", + "kind": "vital", + "source_unit": "PV-DIRECTION-INCOMPLETE-001", + "given": { + "vital": "direction", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "file", + "debt_mapping": null + }, + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "T-1", + "title": "target T-1", + "state": "OPEN", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "file" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": null, + "target_state": null, + "target_refs": [], + "linkage_unresolved": [] + }, + { + "number": 2, + "title": "change 2", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": null, + "target_state": null, + "target_refs": [], + "linkage_unresolved": [] + }, + { + "number": 3, + "title": "change 3", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": null, + "target_state": null, + "target_refs": [], + "linkage_unresolved": [] + }, + { + "number": 4, + "title": "change 4", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-1", + "target_state": "UNKNOWN", + "target_refs": [ + { + "target_id": "T-1", + "state": "UNKNOWN" + } + ], + "linkage_unresolved": [] + }, + { + "number": 5, + "title": "change 5", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": null, + "target_state": null, + "target_refs": [], + "linkage_unresolved": [ + "title:int" + ] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + }, + "expect": { + "band": "SCATTERED", + "evaluation_status": "DEGRADED", + "band_semantics": "EXACT", + "possible_bands": [ + "SCATTERED" + ], + "rule_id": "direction.bands.v2", + "derived": { + "active_change_count_28d": 5, + "linked_active_change_count_28d": 0, + "unlinked_active_change_count_28d": 3, + "unresolved_active_change_count_28d": 2, + "possible_bands_semantics": "NON_AUTHORITATIVE_CONSERVATIVE_SUPERSET" + }, + "diagnostics": [ + "DIRECTION_INCOMPLETE_BAND_INVARIANT" + ], + "diagnostics_absent": [ + "DIRECTION_INCOMPLETE_AMBIGUOUS" + ] + } + }, + { + "case": "DIR-INCOMPLETE-12", + "title": "partial link evidence admits MIXED and FULLY_LINKED, so no band", + "kind": "vital", + "source_unit": "PV-DIRECTION-INCOMPLETE-001", + "given": { + "vital": "direction", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "file", + "debt_mapping": null + }, + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "T-1", + "title": "target T-1", + "state": "OPEN", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "file" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-1", + "target_state": "OPEN", + "target_refs": [ + { + "target_id": "T-1", + "state": "OPEN" + } + ], + "linkage_unresolved": [] + }, + { + "number": 2, + "title": "change 2", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-1", + "target_state": "OPEN", + "target_refs": [ + { + "target_id": "T-1", + "state": "OPEN" + } + ], + "linkage_unresolved": [] + }, + { + "number": 3, + "title": "change 3", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-2", + "target_state": "UNKNOWN", + "target_refs": [ + { + "target_id": "T-2", + "state": "UNKNOWN" + } + ], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "direction.bands.v2", + "derived": { + "active_change_count_28d": 3, + "linked_active_change_count_28d": 2, + "unlinked_active_change_count_28d": 0, + "unresolved_active_change_count_28d": 1, + "reachable_bands": [ + "MIXED", + "FULLY_LINKED" + ] + }, + "diagnostics": [ + "DIRECTION_INCOMPLETE_AMBIGUOUS" + ], + "diagnostics_absent": [ + "DIRECTION_INCOMPLETE_BAND_INVARIANT" + ] + } + }, + { + "case": "DIR-INCOMPLETE-13", + "title": "linkage capability that is neither positively present nor absent is UNKNOWN with no band", + "kind": "vital", + "source_unit": "PV-DIRECTION-INCOMPLETE-001", + "given": { + "vital": "direction", + "observed_at": "2026-09-07T12:00:00Z", + "variants": [ + { + "title": "capability UNKNOWN", + "observations": [ + { + "observation_id": "planning.explicit_targets.capability", + "value_type": "enum", + "status": "UNKNOWN", + "reason_code": "TARGETS_INCOMPLETE" + }, + { + "observation_id": "planning.linkage.active_change_requests_count_28d", + "value": 3 + }, + { + "observation_id": "planning.linkage.active_change_requests_linked_count_28d", + "value": 1 + }, + { + "observation_id": "planning.linkage.active_change_requests_unlinked_count_28d", + "value": 1 + }, + { + "observation_id": "planning.linkage.active_change_requests_unresolved_count_28d", + "value": 1 + } + ] + }, + { + "title": "capability PARTIAL", + "observations": [ + { + "observation_id": "planning.explicit_targets.capability", + "value_type": "enum", + "status": "PARTIAL", + "reason_code": "TARGETS_INCOMPLETE" + }, + { + "observation_id": "planning.linkage.active_change_requests_count_28d", + "value": 3 + }, + { + "observation_id": "planning.linkage.active_change_requests_linked_count_28d", + "value": 1 + }, + { + "observation_id": "planning.linkage.active_change_requests_unlinked_count_28d", + "value": 1 + }, + { + "observation_id": "planning.linkage.active_change_requests_unresolved_count_28d", + "value": 1 + } + ] + }, + { + "title": "capability UNAVAILABLE", + "observations": [ + { + "observation_id": "planning.explicit_targets.capability", + "value_type": "enum", + "status": "UNAVAILABLE", + "reason_code": "TARGETS_INCOMPLETE" + }, + { + "observation_id": "planning.linkage.active_change_requests_count_28d", + "value": 3 + }, + { + "observation_id": "planning.linkage.active_change_requests_linked_count_28d", + "value": 1 + }, + { + "observation_id": "planning.linkage.active_change_requests_unlinked_count_28d", + "value": 1 + }, + { + "observation_id": "planning.linkage.active_change_requests_unresolved_count_28d", + "value": 1 + } + ] + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "direction.bands.v2", + "diagnostics": [ + "MISSING_REQUIRED:planning.explicit_targets.capability" + ] + } + }, + { + "case": "DIR-INCOMPLETE-14", + "title": "an active-change inventory that is PARTIAL leaves N unproven: UNKNOWN with no band", + "kind": "vital", + "source_unit": "PV-DIRECTION-INCOMPLETE-001", + "given": { + "vital": "direction", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "OPEN", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "milestones" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "1", + "target_state": "OPEN", + "target_refs": [ + { + "target_id": "1", + "state": "OPEN" + } + ], + "linkage_unresolved": [] + }, + { + "number": 2, + "title": "change 2", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": null, + "target_state": null, + "target_refs": [], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": false, + "window_start": "2026-08-10T12:00:00Z" + }, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED" + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "direction.bands.v2", + "diagnostics": [ + "MISSING_REQUIRED:planning.linkage.active_change_requests_count_28d:PARTIAL" + ] + } + }, + { + "case": "DIR-INCOMPLETE-15", + "title": "reversed and reshuffled enumeration, page boundaries, marker order and target order leave L/U/R, the reachable bands, the status, the band and the diagnostics unchanged", + "kind": "vital", + "source_unit": "PV-DIRECTION-INCOMPLETE-001", + "given": { + "vital": "direction", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "file", + "debt_mapping": null + }, + "variants": [ + { + "title": "provider order", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "T-1", + "title": "target T-1", + "state": "OPEN", + "due_at": null + }, + { + "target_id": "T-2", + "title": "target T-2", + "state": "CLOSED", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "file" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-1", + "target_state": "OPEN", + "target_refs": [ + { + "target_id": "T-1", + "state": "OPEN" + } + ], + "linkage_unresolved": [] + }, + { + "number": 2, + "title": "change 2", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-2", + "target_state": "CLOSED", + "target_refs": [ + { + "target_id": "T-2", + "state": "CLOSED" + }, + { + "target_id": "T-1", + "state": "OPEN" + } + ], + "linkage_unresolved": [] + }, + { + "number": 3, + "title": "change 3", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-2", + "target_state": "CLOSED", + "target_refs": [ + { + "target_id": "T-2", + "state": "CLOSED" + } + ], + "linkage_unresolved": [] + }, + { + "number": 4, + "title": "change 4", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": null, + "target_state": null, + "target_refs": [], + "linkage_unresolved": [] + }, + { + "number": 5, + "title": "change 5", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-3", + "target_state": "UNKNOWN", + "target_refs": [ + { + "target_id": "T-3", + "state": "UNKNOWN" + } + ], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + }, + { + "title": "reversed, markers and targets reordered", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "T-2", + "title": "target T-2", + "state": "CLOSED", + "due_at": null + }, + { + "target_id": "T-1", + "title": "target T-1", + "state": "OPEN", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "file" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 5, + "title": "change 5", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-3", + "target_state": "UNKNOWN", + "target_refs": [ + { + "target_id": "T-3", + "state": "UNKNOWN" + } + ], + "linkage_unresolved": [] + }, + { + "number": 4, + "title": "change 4", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": null, + "target_state": null, + "target_refs": [], + "linkage_unresolved": [] + }, + { + "number": 3, + "title": "change 3", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-2", + "target_state": "CLOSED", + "target_refs": [ + { + "target_id": "T-2", + "state": "CLOSED" + } + ], + "linkage_unresolved": [] + }, + { + "number": 2, + "title": "change 2", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-1", + "target_state": "OPEN", + "target_refs": [ + { + "target_id": "T-1", + "state": "OPEN" + }, + { + "target_id": "T-2", + "state": "CLOSED" + } + ], + "linkage_unresolved": [] + }, + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-1", + "target_state": "OPEN", + "target_refs": [ + { + "target_id": "T-1", + "state": "OPEN" + } + ], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + }, + { + "title": "interleaved", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "T-1", + "title": "target T-1", + "state": "OPEN", + "due_at": null + }, + { + "target_id": "T-2", + "title": "target T-2", + "state": "CLOSED", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "file" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 3, + "title": "change 3", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-2", + "target_state": "CLOSED", + "target_refs": [ + { + "target_id": "T-2", + "state": "CLOSED" + } + ], + "linkage_unresolved": [] + }, + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-1", + "target_state": "OPEN", + "target_refs": [ + { + "target_id": "T-1", + "state": "OPEN" + } + ], + "linkage_unresolved": [] + }, + { + "number": 5, + "title": "change 5", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-3", + "target_state": "UNKNOWN", + "target_refs": [ + { + "target_id": "T-3", + "state": "UNKNOWN" + } + ], + "linkage_unresolved": [] + }, + { + "number": 2, + "title": "change 2", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-2", + "target_state": "CLOSED", + "target_refs": [ + { + "target_id": "T-2", + "state": "CLOSED" + }, + { + "target_id": "T-1", + "state": "OPEN" + } + ], + "linkage_unresolved": [] + }, + { + "number": 4, + "title": "change 4", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": null, + "target_state": null, + "target_refs": [], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + } + ] + }, + "expect": { + "band": "MIXED", + "evaluation_status": "DEGRADED", + "band_semantics": "EXACT", + "possible_bands": [ + "MIXED" + ], + "rule_id": "direction.bands.v2", + "derived": { + "active_change_count_28d": 5, + "linked_active_change_count_28d": 3, + "unlinked_active_change_count_28d": 1, + "unresolved_active_change_count_28d": 1, + "links_per_target": { + "T-1": 2, + "T-2": 2 + }, + "possible_bands_semantics": "NON_AUTHORITATIVE_CONSERVATIVE_SUPERSET" + }, + "diagnostics": [ + "DIRECTION_INCOMPLETE_BAND_INVARIANT" + ], + "diagnostics_absent": [ + "DIRECTION_INCOMPLETE_AMBIGUOUS" + ] + } + }, + { + "case": "DIR-INCOMPLETE-16", + "title": "several active change requests all linked to one authoritatively resolved closed target, with complete evidence, are exactly FULLY_LINKED: neutral, no concentration threshold", + "kind": "vital", + "source_unit": "PV-DIRECTION-INCOMPLETE-001", + "given": { + "vital": "direction", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "file", + "debt_mapping": null + }, + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "T-9", + "title": "target T-9", + "state": "CLOSED", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "file" + } + }, + { + "observation_id": "forge.change_requests.inventory", + "value_type": "series", + "value": [ + { + "number": 1, + "title": "change 1", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-9", + "target_state": "CLOSED", + "target_refs": [ + { + "target_id": "T-9", + "state": "CLOSED" + } + ], + "linkage_unresolved": [] + }, + { + "number": 2, + "title": "change 2", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-9", + "target_state": "CLOSED", + "target_refs": [ + { + "target_id": "T-9", + "state": "CLOSED" + } + ], + "linkage_unresolved": [] + }, + { + "number": 3, + "title": "change 3", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-9", + "target_state": "CLOSED", + "target_refs": [ + { + "target_id": "T-9", + "state": "CLOSED" + } + ], + "linkage_unresolved": [] + }, + { + "number": 4, + "title": "change 4", + "state": "OPEN", + "draft": false, + "created_at": "2026-08-20T10:00:00Z", + "updated_at": "2026-09-01T10:00:00Z", + "merged_at": null, + "closed_at": null, + "target_id": "T-9", + "target_state": "CLOSED", + "target_refs": [ + { + "target_id": "T-9", + "state": "CLOSED" + } + ], + "linkage_unresolved": [] + } + ], + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-10T12:00:00Z" + } + } + ] + }, + "expect": { + "band": "FULLY_LINKED", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "direction.bands.v2", + "derived": { + "active_change_count_28d": 4, + "linked_active_change_count_28d": 4, + "unlinked_active_change_count_28d": 0, + "unresolved_active_change_count_28d": 0, + "links_per_target": { + "T-9": 4 + } + }, + "diagnostics": [ + "ALL_LINKS_TO_SINGLE_TARGET" + ], + "diagnostics_absent": [ + "DIRECTION_INCOMPLETE_BAND_INVARIANT", + "DIRECTION_INCOMPLETE_AMBIGUOUS" + ] + } + } + ] +} diff --git a/tests/vectors/horizon-partial.json b/tests/vectors/horizon-partial.json new file mode 100644 index 0000000..4ecc3fb --- /dev/null +++ b/tests/vectors/horizon-partial.json @@ -0,0 +1,1330 @@ +{ + "schema": "devostasis.vectors.v1", + "notes": "Cases HOR-PARTIAL-01..16 of PV-HORIZON-PARTIAL-001 (accepted by PV-REV-HORIZON-PARTIAL-001), transcribed from the contract's required fixtures and adopted in 0.2.0 as horizon.bands.v2. Target evidence is stated as the provider-neutral target inventory and derived (given.derive), so the counts, their observed-subset proof and the capability a returned target proves are executed where they are made. observed_at is 2026-09-07T12:00:00Z; the 28-day frame ends at 2026-10-05T12:00:00Z, which is not beyond it. HOR-PARTIAL-15 states the contradictory counts directly: the derivation cannot produce them, and the contract makes them a validation failure, which a vector states with expect.rejected.", + "vectors": [ + { + "case": "HOR-PARTIAL-01", + "title": "capability present, complete and fresh, zero open targets: exactly UNDECLARED (the positive-zero control)", + "kind": "vital", + "source_unit": "PV-HORIZON-PARTIAL-001", + "given": { + "vital": "horizon", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "CLOSED", + "due_at": "2026-08-01T00:00:00Z" + } + ], + "coverage": { + "complete": true, + "source": "milestones" + } + } + ] + }, + "expect": { + "band": "UNDECLARED", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "horizon.bands.v2", + "derived": { + "capability": "SUPPORTED", + "open_count": 0, + "open_with_future_boundary_count": 0, + "open_beyond_28d_count": 0 + } + } + }, + { + "case": "HOR-PARTIAL-02", + "title": "complete and fresh, one open target with no future boundary: exactly DECLARED", + "kind": "vital", + "source_unit": "PV-HORIZON-PARTIAL-001", + "given": { + "vital": "horizon", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "OPEN", + "due_at": null + } + ], + "coverage": { + "complete": true, + "source": "milestones" + } + } + ] + }, + "expect": { + "band": "DECLARED", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "horizon.bands.v2", + "derived": { + "capability": "SUPPORTED", + "open_count": 1, + "open_with_future_boundary_count": 0, + "open_beyond_28d_count": 0 + } + } + }, + { + "case": "HOR-PARTIAL-03", + "title": "complete and fresh, an open target exactly 28 days out and none later: exactly VISIBLE; the frame is unchanged", + "kind": "vital", + "source_unit": "PV-HORIZON-PARTIAL-001", + "given": { + "vital": "horizon", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "OPEN", + "due_at": "2026-10-05T12:00:00Z" + } + ], + "coverage": { + "complete": true, + "source": "milestones" + } + } + ] + }, + "expect": { + "band": "VISIBLE", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "horizon.bands.v2", + "derived": { + "capability": "SUPPORTED", + "open_count": 1, + "open_with_future_boundary_count": 1, + "open_beyond_28d_count": 0, + "nearest_future_boundary_days": 28 + } + } + }, + { + "case": "HOR-PARTIAL-04", + "title": "complete and fresh, an open target strictly beyond 28 days: exactly EXTENDED", + "kind": "vital", + "source_unit": "PV-HORIZON-PARTIAL-001", + "given": { + "vital": "horizon", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "OPEN", + "due_at": "2026-10-05T12:00:01Z" + } + ], + "coverage": { + "complete": true, + "source": "milestones" + } + } + ] + }, + "expect": { + "band": "EXTENDED", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "horizon.bands.v2", + "derived": { + "capability": "SUPPORTED", + "open_count": 1, + "open_with_future_boundary_count": 1, + "open_beyond_28d_count": 1 + } + } + }, + { + "case": "HOR-PARTIAL-05", + "title": "explicit targets positively unsupported and no planning register configured: exactly UNDECLARED, a descriptive state, not missing evidence", + "kind": "vital", + "source_unit": "PV-HORIZON-PARTIAL-001", + "given": { + "vital": "horizon", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "none", + "debt_mapping": null + }, + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "status": "UNAVAILABLE", + "reason_code": "PLANNING_SOURCE_NONE" + } + ] + }, + "expect": { + "band": "UNDECLARED", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "horizon.bands.v2", + "derived": { + "capability": "UNSUPPORTED" + }, + "diagnostics": [ + "PLANNING_CAPABILITY:UNSUPPORTED" + ] + } + }, + { + "case": "HOR-PARTIAL-06", + "title": "capability unknown, forbidden, errored, stale, unavailable or an unresolved partial: UNKNOWN, never UNDECLARED by default", + "kind": "vital", + "source_unit": "PV-HORIZON-PARTIAL-001", + "given": { + "vital": "horizon", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "variants": [ + { + "title": "targets UNKNOWN", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "status": "UNKNOWN", + "reason_code": "CONNECTOR_AMBIGUOUS" + } + ] + }, + { + "title": "targets FORBIDDEN", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "status": "FORBIDDEN", + "reason_code": "FORBIDDEN" + } + ] + }, + { + "title": "targets ERROR", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "status": "ERROR", + "reason_code": "UNEXPECTED_PAYLOAD" + } + ] + }, + { + "title": "targets UNAVAILABLE", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "status": "UNAVAILABLE", + "reason_code": "REGISTER_NOT_FOUND" + } + ] + }, + { + "title": "targets STALE", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "OPEN", + "due_at": "2026-10-05T12:00:01Z" + } + ], + "coverage": { + "complete": true, + "source": "milestones" + }, + "freshness": "STALE" + } + ] + }, + { + "title": "partial with no returned target", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [], + "coverage": { + "complete": false, + "source": "milestones" + }, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED" + } + ] + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "horizon.bands.v2", + "diagnostics": [ + "MISSING_REQUIRED:planning.explicit_targets.capability" + ] + } + }, + { + "case": "HOR-PARTIAL-07", + "title": "PH0: a partial enumeration returned no open target, so every band is reachable: UNKNOWN, never UNDECLARED", + "kind": "vital", + "source_unit": "PV-HORIZON-PARTIAL-001", + "given": { + "vital": "horizon", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "CLOSED", + "due_at": "2026-08-01T00:00:00Z" + } + ], + "coverage": { + "complete": false, + "source": "milestones" + }, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED" + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "horizon.bands.v2", + "derived": { + "capability": "SUPPORTED", + "open_count": 0, + "open_with_future_boundary_count": 0, + "open_beyond_28d_count": 0, + "open_count_semantics": "LOWER_BOUND", + "open_with_future_boundary_count_semantics": "LOWER_BOUND", + "open_beyond_28d_count_semantics": "LOWER_BOUND", + "reachable_bands": [ + "UNDECLARED", + "DECLARED", + "VISIBLE", + "EXTENDED" + ] + }, + "diagnostics": [ + "HORIZON_PARTIAL_AMBIGUOUS", + "PARTIAL_ENUMERATION:planning.explicit_targets.open_count" + ], + "diagnostics_absent": [ + "HORIZON_PARTIAL_BAND_INVARIANT" + ] + } + }, + { + "case": "HOR-PARTIAL-08", + "title": "PH1: returned open targets without a future boundary reach DECLARED, VISIBLE and EXTENDED: UNKNOWN, DECLARED is never a representative", + "kind": "vital", + "source_unit": "PV-HORIZON-PARTIAL-001", + "given": { + "vital": "horizon", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "OPEN", + "due_at": null + }, + { + "target_id": "2", + "title": "target 2", + "state": "OPEN", + "due_at": null + } + ], + "coverage": { + "complete": false, + "source": "milestones" + }, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED" + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "horizon.bands.v2", + "derived": { + "capability": "SUPPORTED", + "open_count": 2, + "open_with_future_boundary_count": 0, + "open_beyond_28d_count": 0, + "open_count_semantics": "LOWER_BOUND", + "open_with_future_boundary_count_semantics": "LOWER_BOUND", + "open_beyond_28d_count_semantics": "LOWER_BOUND", + "reachable_bands": [ + "DECLARED", + "VISIBLE", + "EXTENDED" + ] + }, + "diagnostics": [ + "HORIZON_PARTIAL_AMBIGUOUS", + "PARTIAL_ENUMERATION:planning.explicit_targets.open_count" + ], + "diagnostics_absent": [ + "HORIZON_PARTIAL_BAND_INVARIANT" + ] + } + }, + { + "case": "HOR-PARTIAL-09", + "title": "PH2: a returned open target within or exactly at 28 days reaches VISIBLE and EXTENDED: UNKNOWN, VISIBLE is never a bound", + "kind": "vital", + "source_unit": "PV-HORIZON-PARTIAL-001", + "given": { + "vital": "horizon", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "variants": [ + { + "title": "within the frame", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "OPEN", + "due_at": "2026-09-20T00:00:00Z" + } + ], + "coverage": { + "complete": false, + "source": "milestones" + }, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED" + } + ] + }, + { + "title": "exactly at 28 days", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "OPEN", + "due_at": "2026-10-05T12:00:00Z" + } + ], + "coverage": { + "complete": false, + "source": "milestones" + }, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED" + } + ] + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "horizon.bands.v2", + "derived": { + "capability": "SUPPORTED", + "open_count": 1, + "open_with_future_boundary_count": 1, + "open_beyond_28d_count": 0, + "open_count_semantics": "LOWER_BOUND", + "open_with_future_boundary_count_semantics": "LOWER_BOUND", + "open_beyond_28d_count_semantics": "LOWER_BOUND", + "reachable_bands": [ + "VISIBLE", + "EXTENDED" + ] + }, + "diagnostics": [ + "HORIZON_PARTIAL_AMBIGUOUS", + "PARTIAL_ENUMERATION:planning.explicit_targets.open_count" + ], + "diagnostics_absent": [ + "HORIZON_PARTIAL_BAND_INVARIANT" + ] + } + }, + { + "case": "HOR-PARTIAL-10", + "title": "PH3: a returned open target strictly beyond 28 days forces EXTENDED in every completion: DEGRADED / EXTENDED / EXACT", + "kind": "vital", + "source_unit": "PV-HORIZON-PARTIAL-001", + "given": { + "vital": "horizon", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "OPEN", + "due_at": "2026-10-05T12:00:01Z" + }, + { + "target_id": "2", + "title": "target 2", + "state": "OPEN", + "due_at": null + } + ], + "coverage": { + "complete": false, + "source": "milestones" + }, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED" + } + ] + }, + "expect": { + "band": "EXTENDED", + "evaluation_status": "DEGRADED", + "band_semantics": "EXACT", + "possible_bands": [ + "EXTENDED" + ], + "rule_id": "horizon.bands.v2", + "derived": { + "capability": "SUPPORTED", + "open_count": 2, + "open_with_future_boundary_count": 1, + "open_beyond_28d_count": 1, + "open_count_semantics": "LOWER_BOUND", + "open_with_future_boundary_count_semantics": "LOWER_BOUND", + "open_beyond_28d_count_semantics": "LOWER_BOUND", + "reachable_bands": [ + "EXTENDED" + ], + "possible_bands_semantics": "NON_AUTHORITATIVE_CONSERVATIVE_SUPERSET" + }, + "diagnostics": [ + "HORIZON_PARTIAL_BAND_INVARIANT" + ], + "diagnostics_absent": [ + "HORIZON_PARTIAL_AMBIGUOUS" + ] + } + }, + { + "case": "HOR-PARTIAL-11", + "title": "a returned closed target beyond 28 days and an open target without a boundary: the closed one forces nothing, PH1, UNKNOWN", + "kind": "vital", + "source_unit": "PV-HORIZON-PARTIAL-001", + "given": { + "vital": "horizon", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "CLOSED", + "due_at": "2026-12-01T00:00:00Z" + }, + { + "target_id": "2", + "title": "target 2", + "state": "OPEN", + "due_at": null + } + ], + "coverage": { + "complete": false, + "source": "milestones" + }, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED" + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "horizon.bands.v2", + "derived": { + "capability": "SUPPORTED", + "open_count": 1, + "open_with_future_boundary_count": 0, + "open_beyond_28d_count": 0, + "open_count_semantics": "LOWER_BOUND", + "open_with_future_boundary_count_semantics": "LOWER_BOUND", + "open_beyond_28d_count_semantics": "LOWER_BOUND", + "reachable_bands": [ + "DECLARED", + "VISIBLE", + "EXTENDED" + ] + }, + "diagnostics": [ + "HORIZON_PARTIAL_AMBIGUOUS", + "PARTIAL_ENUMERATION:planning.explicit_targets.open_count" + ], + "diagnostics_absent": [ + "HORIZON_PARTIAL_BAND_INVARIANT" + ] + } + }, + { + "case": "HOR-PARTIAL-12", + "title": "only closed returned targets, one beyond 28 days: PH0, UNKNOWN, never UNDECLARED or EXTENDED", + "kind": "vital", + "source_unit": "PV-HORIZON-PARTIAL-001", + "given": { + "vital": "horizon", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "CLOSED", + "due_at": "2026-12-01T00:00:00Z" + }, + { + "target_id": "2", + "title": "target 2", + "state": "CLOSED", + "due_at": "2026-08-01T00:00:00Z" + } + ], + "coverage": { + "complete": false, + "source": "milestones" + }, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED" + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "horizon.bands.v2", + "derived": { + "capability": "SUPPORTED", + "open_count": 0, + "open_with_future_boundary_count": 0, + "open_beyond_28d_count": 0, + "open_count_semantics": "LOWER_BOUND", + "open_with_future_boundary_count_semantics": "LOWER_BOUND", + "open_beyond_28d_count_semantics": "LOWER_BOUND", + "reachable_bands": [ + "UNDECLARED", + "DECLARED", + "VISIBLE", + "EXTENDED" + ] + }, + "diagnostics": [ + "HORIZON_PARTIAL_AMBIGUOUS", + "PARTIAL_ENUMERATION:planning.explicit_targets.open_count" + ], + "diagnostics_absent": [ + "HORIZON_PARTIAL_BAND_INVARIANT" + ] + } + }, + { + "case": "HOR-PARTIAL-13", + "title": "the same PH3 evidence in reversed order, other page boundaries and an equivalent provider-neutral record shape: identical DEGRADED / EXTENDED / EXACT", + "kind": "vital", + "source_unit": "PV-HORIZON-PARTIAL-001", + "given": { + "vital": "horizon", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "variants": [ + { + "title": "provider order", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "OPEN", + "due_at": "2026-10-05T12:00:01Z" + }, + { + "target_id": "2", + "title": "target 2", + "state": "OPEN", + "due_at": "2026-09-20T00:00:00Z" + }, + { + "target_id": "3", + "title": "target 3", + "state": "CLOSED", + "due_at": "2026-08-01T00:00:00Z" + } + ], + "coverage": { + "complete": false, + "source": "milestones" + }, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED" + } + ] + }, + { + "title": "reversed, a different cap", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "3", + "title": "target 3", + "state": "CLOSED", + "due_at": "2026-08-01T00:00:00Z" + }, + { + "target_id": "2", + "title": "target 2", + "state": "OPEN", + "due_at": "2026-09-20T00:00:00Z" + }, + { + "target_id": "1", + "title": "target 1", + "state": "OPEN", + "due_at": "2026-10-05T12:00:01Z" + } + ], + "coverage": { + "complete": false, + "source": "milestones" + }, + "status": "PARTIAL", + "reason_code": "REQUEST_BUDGET_EXHAUSTED" + } + ] + }, + { + "title": "register-shaped records with provider extras", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "T-3", + "title": "target T-3", + "state": "CLOSED", + "due_at": "2026-08-01T00:00:00Z", + "url": "https://example.invalid/t3" + }, + { + "target_id": "T-1", + "title": "target T-1", + "state": "OPEN", + "due_at": "2026-10-05T12:00:01Z", + "open_items": 4, + "closed_items": 1 + }, + { + "target_id": "T-2", + "title": "target T-2", + "state": "OPEN", + "due_at": "2026-09-20T00:00:00Z", + "url": "https://example.invalid/t2" + } + ], + "coverage": { + "complete": false, + "source": "file" + }, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED" + } + ] + } + ] + }, + "expect": { + "band": "EXTENDED", + "evaluation_status": "DEGRADED", + "band_semantics": "EXACT", + "possible_bands": [ + "EXTENDED" + ], + "rule_id": "horizon.bands.v2", + "derived": { + "capability": "SUPPORTED", + "open_count": 2, + "open_with_future_boundary_count": 2, + "open_beyond_28d_count": 1, + "open_count_semantics": "LOWER_BOUND", + "open_with_future_boundary_count_semantics": "LOWER_BOUND", + "open_beyond_28d_count_semantics": "LOWER_BOUND", + "reachable_bands": [ + "EXTENDED" + ], + "possible_bands_semantics": "NON_AUTHORITATIVE_CONSERVATIVE_SUPERSET" + }, + "diagnostics": [ + "HORIZON_PARTIAL_BAND_INVARIANT" + ], + "diagnostics_absent": [ + "HORIZON_PARTIAL_AMBIGUOUS" + ] + } + }, + { + "case": "HOR-PARTIAL-14", + "title": "the same PH2 subset under record and page permutations: identical UNKNOWN, pagination order cannot manufacture exactness", + "kind": "vital", + "source_unit": "PV-HORIZON-PARTIAL-001", + "given": { + "vital": "horizon", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "variants": [ + { + "title": "provider order", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "OPEN", + "due_at": "2026-09-20T00:00:00Z" + }, + { + "target_id": "2", + "title": "target 2", + "state": "OPEN", + "due_at": null + }, + { + "target_id": "3", + "title": "target 3", + "state": "CLOSED", + "due_at": "2026-12-01T00:00:00Z" + } + ], + "coverage": { + "complete": false, + "source": "milestones" + }, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED" + } + ] + }, + { + "title": "reversed", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "3", + "title": "target 3", + "state": "CLOSED", + "due_at": "2026-12-01T00:00:00Z" + }, + { + "target_id": "2", + "title": "target 2", + "state": "OPEN", + "due_at": null + }, + { + "target_id": "1", + "title": "target 1", + "state": "OPEN", + "due_at": "2026-09-20T00:00:00Z" + } + ], + "coverage": { + "complete": false, + "source": "milestones" + }, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED" + } + ] + }, + { + "title": "interleaved, another cap", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "2", + "title": "target 2", + "state": "OPEN", + "due_at": null + }, + { + "target_id": "3", + "title": "target 3", + "state": "CLOSED", + "due_at": "2026-12-01T00:00:00Z" + }, + { + "target_id": "1", + "title": "target 1", + "state": "OPEN", + "due_at": "2026-09-20T00:00:00Z" + } + ], + "coverage": { + "complete": false, + "source": "milestones" + }, + "status": "PARTIAL", + "reason_code": "REQUEST_BUDGET_EXHAUSTED" + } + ] + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "horizon.bands.v2", + "derived": { + "capability": "SUPPORTED", + "open_count": 2, + "open_with_future_boundary_count": 1, + "open_beyond_28d_count": 0, + "open_count_semantics": "LOWER_BOUND", + "open_with_future_boundary_count_semantics": "LOWER_BOUND", + "open_beyond_28d_count_semantics": "LOWER_BOUND", + "reachable_bands": [ + "VISIBLE", + "EXTENDED" + ] + }, + "diagnostics": [ + "HORIZON_PARTIAL_AMBIGUOUS", + "PARTIAL_ENUMERATION:planning.explicit_targets.open_count" + ], + "diagnostics_absent": [ + "HORIZON_PARTIAL_BAND_INVARIANT" + ] + } + }, + { + "case": "HOR-PARTIAL-15", + "title": "a derived tuple no target population can produce (beyond above future, future above open) fails validation before evaluation, never UNKNOWN or DEGRADED compensation", + "kind": "vital", + "source_unit": "PV-HORIZON-PARTIAL-001", + "given": { + "vital": "horizon", + "observed_at": "2026-09-07T12:00:00Z", + "variants": [ + { + "title": "partial, beyond above future", + "observations": [ + { + "observation_id": "planning.explicit_targets.capability", + "value_type": "enum", + "value": "SUPPORTED" + }, + { + "observation_id": "planning.explicit_targets.open_count", + "value": 2, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED", + "coverage": { + "complete": false, + "value_semantics": "OBSERVED_SUBSET_COUNT" + } + }, + { + "observation_id": "planning.explicit_targets.open_with_future_boundary_count", + "value": 1, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED", + "coverage": { + "complete": false, + "value_semantics": "OBSERVED_SUBSET_COUNT" + } + }, + { + "observation_id": "planning.explicit_targets.open_beyond_28d_count", + "value": 2, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED", + "coverage": { + "complete": false, + "value_semantics": "OBSERVED_SUBSET_COUNT" + } + } + ] + }, + { + "title": "partial, future above open", + "observations": [ + { + "observation_id": "planning.explicit_targets.capability", + "value_type": "enum", + "value": "SUPPORTED" + }, + { + "observation_id": "planning.explicit_targets.open_count", + "value": 1, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED", + "coverage": { + "complete": false, + "value_semantics": "OBSERVED_SUBSET_COUNT" + } + }, + { + "observation_id": "planning.explicit_targets.open_with_future_boundary_count", + "value": 2, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED", + "coverage": { + "complete": false, + "value_semantics": "OBSERVED_SUBSET_COUNT" + } + }, + { + "observation_id": "planning.explicit_targets.open_beyond_28d_count", + "value": 0, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED", + "coverage": { + "complete": false, + "value_semantics": "OBSERVED_SUBSET_COUNT" + } + } + ] + }, + { + "title": "complete, beyond above future", + "observations": [ + { + "observation_id": "planning.explicit_targets.capability", + "value_type": "enum", + "value": "SUPPORTED" + }, + { + "observation_id": "planning.explicit_targets.open_count", + "value": 3 + }, + { + "observation_id": "planning.explicit_targets.open_with_future_boundary_count", + "value": 0 + }, + { + "observation_id": "planning.explicit_targets.open_beyond_28d_count", + "value": 1 + } + ] + } + ] + }, + "expect": { + "rejected": "HORIZON_COUNTS_INCONSISTENT" + } + }, + { + "case": "HOR-PARTIAL-16", + "title": "PH3 is the direct proof: whatever else the returned subset holds, and whatever the omitted records are, the observed open target beyond 28 days keeps every completion EXTENDED", + "kind": "vital", + "source_unit": "PV-HORIZON-PARTIAL-001", + "given": { + "vital": "horizon", + "observed_at": "2026-09-07T12:00:00Z", + "derive": { + "planning_source": "milestones", + "debt_mapping": null + }, + "variants": [ + { + "title": "beside closed targets", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "OPEN", + "due_at": "2026-12-01T00:00:00Z" + }, + { + "target_id": "2", + "title": "target 2", + "state": "CLOSED", + "due_at": "2026-08-01T00:00:00Z" + }, + { + "target_id": "3", + "title": "target 3", + "state": "CLOSED", + "due_at": "2026-12-01T00:00:00Z" + } + ], + "coverage": { + "complete": false, + "source": "milestones" + }, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED" + } + ] + }, + { + "title": "beside an open target without a boundary", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "OPEN", + "due_at": "2026-12-01T00:00:00Z" + }, + { + "target_id": "2", + "title": "target 2", + "state": "OPEN", + "due_at": null + } + ], + "coverage": { + "complete": false, + "source": "milestones" + }, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED" + } + ] + }, + { + "title": "beside an open target within the frame", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "OPEN", + "due_at": "2026-12-01T00:00:00Z" + }, + { + "target_id": "2", + "title": "target 2", + "state": "OPEN", + "due_at": "2026-09-20T00:00:00Z" + } + ], + "coverage": { + "complete": false, + "source": "milestones" + }, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED" + } + ] + }, + { + "title": "beside another open target beyond the frame", + "observations": [ + { + "observation_id": "planning.explicit_targets.inventory", + "value_type": "series", + "value": [ + { + "target_id": "1", + "title": "target 1", + "state": "OPEN", + "due_at": "2026-12-01T00:00:00Z" + }, + { + "target_id": "2", + "title": "target 2", + "state": "OPEN", + "due_at": "2026-10-05T12:00:01Z" + } + ], + "coverage": { + "complete": false, + "source": "milestones" + }, + "status": "PARTIAL", + "reason_code": "PAGINATION_CAPPED" + } + ] + } + ] + }, + "expect": { + "band": "EXTENDED", + "evaluation_status": "DEGRADED", + "band_semantics": "EXACT", + "possible_bands": [ + "EXTENDED" + ], + "rule_id": "horizon.bands.v2", + "derived": { + "reachable_bands": [ + "EXTENDED" + ], + "possible_bands_semantics": "NON_AUTHORITATIVE_CONSERVATIVE_SUPERSET" + }, + "diagnostics": [ + "HORIZON_PARTIAL_BAND_INVARIANT" + ] + }, + "notes": "The omitted records of a partial enumeration are not observable, so no vector can list them; each variant is a different returned subset, and the contract's argument is that none of them, nor anything added to them, removes the observed beyond-28-day open target." + } + ] +} From 4de95983923c245963585f726900e860f847cbf8 Mon Sep 17 00:00:00 2001 From: Dreven Date: Thu, 1 Oct 2026 00:56:13 +0300 Subject: [PATCH 05/33] Carry Integrity revision history across bundles (PV-HIST-002, target B3) Integrity history was reconstructed from whatever the provider still showed, so a failure hidden behind a passing re-run on a parent-level surface, or dropped by retention, left the counts as if it had never happened. The accepted contract, PV-HIST-002 (PV-REV-HIST-002), makes history durable. The carrier is the union of every attempt observed per parent per revision. A build now reads its immediate predecessor, the bundle the comparison already resolved, and adds that union as ci.revision_history_carried before evaluating; Integrity merges it with the provider's current parents and emits the result as derived.revision_history in the snapshot, on every path, UNKNOWN ones included, so history also crosses a bundle whose verification evidence could not be read. A missing, unverifiable, foreign or later predecessor is a gap that says so (REVISION_HISTORY_GAP); nothing older is ever carried in its place. verify checks that the source a bundle names is the bundle its manifest follows, and a build refuses observations that already carry history from somewhere else. The state of a revision is a function of its union: an observed failure is FAILURE_OBSERVED on any surface and stays; favorable evidence proves PASS_ONLY_OBSERVED only when every attempt of every parent is observed, so a check-suite pass, or a run whose earlier attempts are not all known, is UNKNOWN_HISTORY (R52), which makes the Vital UNKNOWN, because T9 puts unknown history above every conservative branch. A current verdict is never carried. The first 0.2.0 bundle of a project replays the revision records of its 0.1.9 predecessor (lineage devostasis.ci-history.v1) instead of starting empty. On the stored corpus every active revision is attempt-complete, so no fleet project loses its band to R52; all 326 stored bundle directories still verify. The rule moves to integrity.bands.v1+ci-unit-004+hist-002; the band table and the 0.1.9 judgements are unchanged. HIST-01..07, -09..11, -13, -15, -16 and -19 are vectors, HIST-08, -12, -17, -18 and -20 store tests; HIST-14 has no accepted migration to execute. Co-Authored-By: Claude Opus 5.5 --- docs/spec/PROVENANCE.md | 3 +- docs/spec/bundle.md | 5 +- docs/spec/conformance.md | 38 +- docs/spec/history-and-reports.md | 6 + docs/spec/integrity-ci.md | 86 +- docs/spec/observations.md | 1 + docs/spec/vectors.md | 2 +- docs/spec/vitals.md | 26 +- src/devostasis/adapters/github_ci.py | 31 +- src/devostasis/bundle.py | 43 + src/devostasis/history.py | 7 +- src/devostasis/revision_history.py | 393 +++ src/devostasis/runner.py | 74 +- src/devostasis/vitals/integrity.py | 116 +- tests/conformance/test_integrity.py | 23 +- tests/test_revision_history.py | 268 ++ tests/vectors/integrity-totality.json | 24 +- tests/vectors/integrity-unknown.json | 4 +- tests/vectors/revision-history.json | 3937 +++++++++++++++++++++++++ 19 files changed, 5005 insertions(+), 82 deletions(-) create mode 100644 src/devostasis/revision_history.py create mode 100644 tests/test_revision_history.py create mode 100644 tests/vectors/revision-history.json diff --git a/docs/spec/PROVENANCE.md b/docs/spec/PROVENANCE.md index 74bdd22..b02d1e4 100644 --- a/docs/spec/PROVENANCE.md +++ b/docs/spec/PROVENANCE.md @@ -22,6 +22,7 @@ normative here. The process is domain-neutral and documented in the | `horizon.bands.v2` | PV-HORIZON-PARTIAL-001 (accepted by PV-REV-HORIZON-PARTIAL-001, HOR-PARTIAL-01..16) | accepted; adopted in 0.2.0: over a partial target enumeration a singleton completion set is `DEGRADED / / EXACT` and anything wider `UNKNOWN`; the counts carry the observed-subset proof of PV-REV-PR-031-003 | | `debt.bands.v2` | PV-DEBT-PARTIAL-001 (accepted by PV-REV-DEBT-PARTIAL-001, DEBT-PARTIAL-01..16); the Debt part of issue #39 | accepted; adopted in 0.2.0: a confirmed open item under partial acquisition is `DEGRADED / PRESENT / EXACT`, none confirmed is `UNKNOWN`; a PARTIAL count is read only with its subset proof. The Pulse part of #39 waits for the research answer it asks for | | target marker grammar | PV-AUDIT-TARGET-MARKER-SYNTAX-001 (REPAIR REQUIRED, regressions LINK-MARKER-01..08 known only by category) | repaired in 0.2.0 in the adapter; no Vital rule changes for it, the Direction rule version it lands under moves for the repairs above | +| `integrity.bands.v1+ci-unit-004+hist-002`, `devostasis.ci-history.v2` | PV-HIST-002 (accepted by PV-REV-HIST-002, HIST-01..20; supersedes the PV-HIST-001 candidate judged CHANGES_REQUIRED by PV-REV-HIST-001) | accepted; adopted in 0.2.0 as target B3: the per-revision union of observed attempts is carried from the immediate predecessor (`ci.revision_history_carried` in, `derived.revision_history` out), R52's completeness rule makes favorable incomplete evidence `UNKNOWN_HISTORY`, and 0.1.x revision records are replayed as lineage `devostasis.ci-history.v1`. HIST-14 has no accepted migration to execute; the GitLab variant of HIST-11 waits for C1 | | `PV-REV-ACTIVITY-COVERAGE-001` | the judgement on issue #9 | accepted reading 3, runtime unchanged since 0.1.7; ACT-COV-01..05 adopted in 0.1.9 as `activity` vectors | | `clutter.bands.v1`, `PV-CLUTTER-INCOMPLETE-001` | PV-CLUTTER-INCOMPLETE-001 (accepted by PV-REV-CLUTTER-INCOMPLETE-001 with CLU-INCOMPLETE-01..20), PV-ISSUE-026-RECONCILE-001 (the reading of issue #26 under that contract), PV-REV-PR-031-003 (a PARTIAL count proves a floor only with machine-verifiable subset semantics, CLU-PARTIAL-TRUST-01..08) | accepted; adopted in 0.1.9 as one Clutter rule version with the twenty cases executable; the T7 upper-bound path moved under the same rule id; the subset proof (`value_semantics = OBSERVED_SUBSET_COUNT` in a PARTIAL count's coverage) is part of the same rule version, which 0.1.9 introduces and no stored bundle carries yet | | exact vector families T2/R1/R2, R3, R4, T4/T5/T7 | PV-TEST-VECTORS-002/005/004/007, accepted by PV-REV-TEST-VECTORS-002/005/004/007 | transcribed into `tests/vectors/` in 0.1.9 as the accepted expectations; T5 (`PULSE_ISSUE_ONLY_ACTIVITY`) adopted under `pulse.bands.v1` as the accepted vector requires; T7 (`retention_semantics = UNCLASSIFIED` upper bound) adopted under `clutter.bands.v1`, because the Clutter rule had to move for PV-CLUTTER-INCOMPLETE-001 in the same release and the accepted T7 semantics are unchanged | @@ -42,7 +43,7 @@ PV-CAL-002 and PV-CAL-003: the literal Flow rule with an empty queue (repaired, workflow (confirmed as the evidence-faithful reading); Pulse on bursty solo work (open observation, no rule change); Pulse on capped required enumerations (repaired, `pulse.bands.v1`); reconstruction of Integrity history -from provider-exposed attempts only (open, PV-HIST-001). +from provider-exposed attempts only (repaired in 0.2.0, PV-HIST-002). The research process continues independently. Changes flow into this repository as explicit adoption changes that name the unit they adopt; diff --git a/docs/spec/bundle.md b/docs/spec/bundle.md index 78ce32a..990bbb1 100644 --- a/docs/spec/bundle.md +++ b/docs/spec/bundle.md @@ -154,7 +154,10 @@ directory and performs, in this order: hashes to `source_receipts_digest` (`RECEIPT_DIGEST_MISMATCH`), the receipt inside `observations.json` is that receipt (`RECEIPT_COPY_MISMATCH`), `snapshot.json` names the evidence the bundle - carries (`OBSERVATIONS_DIGEST_MISMATCH`), and `semantic_config`, the field + carries (`OBSERVATIONS_DIGEST_MISMATCH`), the durable revision history the + Integrity result and `observations.json` name was carried from the bundle + the manifest follows (`HISTORY_SOURCE_MISMATCH`, PV-HIST-002), and + `semantic_config`, the field the comparison reads, is exactly the projection of the validated stored config under its schema (`SEMANTIC_CONFIG_MISMATCH`). A manifest that is not an object, or whose `members`, `receipt`, `identity_preimage` or diff --git a/docs/spec/conformance.md b/docs/spec/conformance.md index 1849f81..9a8fbd9 100644 --- a/docs/spec/conformance.md +++ b/docs/spec/conformance.md @@ -40,7 +40,8 @@ citation resolves; both directions are a test (`test_spec_drift.py`). ## Integrity: the newest revision and the sample (PV-REV-INTEGRITY-UNKNOWN-001, PV-REV-TEST-003, PV-REV-TEST-VECTORS-002) -Rule `integrity.bands.v1+ci-unit-004`, adopted in 0.1.9. The six cases of the +Rule `integrity.bands.v1+ci-unit-004`, adopted in 0.1.9; since 0.2.0 the same +cases hold under `integrity.bands.v1+ci-unit-004+hist-002`. The six cases of the accepted judgement on issue #13 are executable; two of them start at the provider-native normalization, because their subject is that `startup_failure` and an unsupported conclusion reach the Vital as `UNKNOWN`. @@ -60,6 +61,41 @@ and an unsupported conclusion reach the Vital as `UNKNOWN`. | record consistency | a record whose contribution contradicts its own history state is a defect, not a pass | `test_a_record_whose_contribution_contradicts_its_history_is_a_defect_not_a_pass` | | unusable series | an unusable required series is UNKNOWN even beside a positive `ci.configured = false` (precedence A of PV-INTEGRITY-TOTALITY-001) | `test_an_unusable_series_is_unknown_even_beside_a_positive_not_configured` | +## Integrity: durable revision history (PV-HIST-002) + +Rule `integrity.bands.v1+ci-unit-004+hist-002`, adopted in 0.2.0 (target B3). +The reconciliation cases state the carried history as the observation a build +adds (`ci.revision_history_carried`) and are vectors; the cases about the +chain, which bundle is the source and what a lost one means, need a history +store and are tests. + +| Case | Meaning | Test | +| --- | --- | --- | +| HIST-01 | a revision a prior bundle proved failing stays FAILURE_OBSERVED when the provider now shows the same revision passing; the current verdict is PASS | `vector:HIST-01` | +| HIST-02 | a revision the prior bundle recorded PASS_ONLY_OBSERVED becomes FAILURE_OBSERVED when a failure of it is newly observed, and stays there | `vector:HIST-02` | +| HIST-03 | a prior failure plus any number of same-revision retries that passed is one failed contribution, whatever the number or order of the retries | `vector:HIST-03` | +| HIST-04 | a failed revision and a newer passing revision are two samples while both are in the window | `vector:HIST-04` | +| HIST-05 | a failure the provider no longer exposes stays historical evidence while its revision is in the window | `vector:HIST-05` | +| HIST-06 | without durable history a parent-level pass cannot prove the earlier attempts: UNKNOWN_HISTORY, no favorable PASS_ONLY reconstruction | `vector:HIST-06`, `test_parent_level_provenance_is_diagnosed_and_a_favorable_one_is_unknown_history` | +| HIST-07 | complete attempt-level current evidence with no unresolved prior gap proves PASS_ONLY_OBSERVED | `vector:HIST-07` | +| HIST-08 | a required predecessor that is missing or unverifiable is a HISTORY_GAP: nothing is carried through it | `test_hist_20_and_hist_08_a_lost_predecessor_is_a_gap_and_nothing_older_is_carried` | +| HIST-09 | the same failure in durable history and in the current enumeration is one failed contribution, never two | `vector:HIST-09` | +| HIST-10 | a revision whose durable failure has aged beyond the 14-day window no longer contributes | `vector:HIST-10` | +| HIST-11 | equivalent failure histories on the Actions and the check-suite surfaces, in any enumeration order, give the identical record and result | `vector:HIST-11` | +| HIST-12 | an unrelated rule or configuration change keeps the durable Integrity history | `test_hist_12_an_unrelated_vital_rule_change_keeps_the_durable_history` | +| HIST-13 | carried history of the older lineage is replayed from the attempts it kept, never copied from the state it derived: its failure stays, its parent-level pass is unknown | `vector:HIST-13`, `test_hist_13_the_first_bundle_after_an_older_version_replays_its_revision_records` | +| HIST-14 | a history-semantics change with an accepted deterministic migration records the migration | not executable: no migration has been accepted, so the mechanism accepts none and a foreign lineage takes the HIST-15 path | +| HIST-15 | carried history of a lineage with neither replay nor an accepted migration leaves the revisions it names unknown, until complete current evidence repairs them | `vector:HIST-15` | +| HIST-16 | a prior bundle's current verdict is never inherited: the revision the provider no longer shows keeps its history and speaks for nothing current | `vector:HIST-16` | +| HIST-17 | the same current observation over different durable histories is different canonical evidence and a different bundle | `test_hist_17_different_durable_history_is_different_canonical_evidence` | +| HIST-18 | history of another immutable project at the same locator is never carried | `test_hist_18_another_project_at_the_same_locator_is_never_carried` | +| HIST-19 | a prior UNKNOWN_HISTORY does not heal because the provider now shows a pass that is complete only for what is visible | `vector:HIST-19`, `test_hist_19_complete_evidence_repairs_an_unknown_history` (the nearby repair) | +| HIST-20 | B1 pass, B2 same-revision failure, B3 pass again: B3 carries from B2 and keeps the failure; with B2 lost it is a gap, never a fallback to B1 | `test_hist_20_the_immediate_predecessor_carries_a_failure_an_older_bundle_did_not_see`, `test_hist_20_and_hist_08_a_lost_predecessor_is_a_gap_and_nothing_older_is_carried` | +| parent-level failure | an observed failure is proven on any surface; only favorable parent-level evidence is unknown | `test_a_parent_level_failure_is_still_a_proven_failure` | +| continuity | the history crosses a bundle whose verification evidence was unusable | `test_the_durable_history_crosses_a_bundle_whose_verification_evidence_was_unusable` | +| source is the predecessor | a build refuses observations that carry history from another bundle, leaves the caller's set untouched, and `verify` names a source that is not the previous bundle | `test_a_build_refuses_observations_that_carry_history_from_another_predecessor`, `test_a_build_leaves_the_callers_observation_set_as_it_was`, `test_verify_names_carried_history_that_is_not_from_the_previous_bundle`, `test_every_stored_bundle_verifies_with_its_carried_history` | +| corrupt carried history | carried history that is not its lineage's shape fails closed | `test_carried_history_that_is_not_its_lineages_shape_fails_closed` | + ## Integrity: totality (PV-INTEGRITY-TOTALITY-001) Accepted by `PV-REV-INT-TOTALITY-001` and adopted in 0.1.9 under the same diff --git a/docs/spec/history-and-reports.md b/docs/spec/history-and-reports.md index 2b2a56a..ace4508 100644 --- a/docs/spec/history-and-reports.md +++ b/docs/spec/history-and-reports.md @@ -132,6 +132,12 @@ still refused (`latest pointer ... differs from index tail`). `HISTORY_GAP` therefore means what its definition says: the previous immutable bundle cannot be loaded or verified. +The same predecessor is the source of the durable Integrity revision +history a build carries (PV-HIST-002): the bundle the comparison resolves, +never an older one, so a `HISTORY_GAP` is also a gap in that history and says +so (`REVISION_HISTORY_GAP`); see +[integrity-ci.md](integrity-ci.md#durable-history-pv-hist-002). + A bundle is routed by what its canonical members say: a wrapper whose `bundle_id` or `project_key` disagrees with its own manifest is refused before anything is written. A locator derives a store path only as a diff --git a/docs/spec/integrity-ci.md b/docs/spec/integrity-ci.md index 99a6250..ed2fef0 100644 --- a/docs/spec/integrity-ci.md +++ b/docs/spec/integrity-ci.md @@ -45,12 +45,17 @@ Per revision, two independent facts are recorded: NON_VERIFY_TERMINAL > NOT_EXECUTED`. An unresolved sibling therefore blocks a pass but never hides an observed failure. 2. **History state** is derived from every attempt observed for the revision: - `FAILURE_OBSERVED` if any attempt was `VERIFY_FAIL`, else - `PASS_ONLY_OBSERVED` if any was `VERIFY_PASS`, else `NO_DECISIVE_OBSERVED`. + `FAILURE_OBSERVED` if any attempt was `VERIFY_FAIL`; otherwise, only when + the history is complete (every parent an Actions run whose attempts + 1..latest are all observed), `PASS_ONLY_OBSERVED` if any was `VERIFY_PASS`, + else `NO_DECISIVE_OBSERVED`; and `UNKNOWN_HISTORY` when it is not complete, + because a check suite keeps its id across re-requests and a missing attempt + may have failed (R52, `history_complete` in the record). `FAILURE_OBSERVED` is absorbing for the life of the revision in the window. The **historical contribution** is `VERIFY_FAIL` for `FAILURE_OBSERVED`, -`VERIFY_PASS` for `PASS_ONLY_OBSERVED`, none otherwise. Exactly one +`VERIFY_PASS` for `PASS_ONLY_OBSERVED`, none otherwise (`UNKNOWN_HISTORY` +included: it is neither a pass nor an empty decisive set). Exactly one contribution per revision enters `decisive_count_14d` and `failed_count_14d`; attempts are never independent samples, and retry count or order cannot improve history (rule `ANY_FAIL_ELSE_ANY_PASS_PER_IMMUTABLE_REVISION`). @@ -85,17 +90,72 @@ reading against a real repository. "current_verdict": "VERIFY_PASS", "history_state": "FAILURE_OBSERVED", "historical_contribution": "VERIFY_FAIL", - "history_provenance": "ATTEMPT_LEVEL" + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true } ``` -## Known limitation of this version - -History is reconstructed from what the provider still exposes at collection -time. Attempt-level surfaces (Actions) preserve failures; parent-level -surfaces (check suites) do not, which is diagnosed as -`HISTORY_PROVENANCE_PARENT_LEVEL_ONLY`. Persisting revision history across -bundles is on the roadmap (PV-HIST-001). +## Durable history (PV-HIST-002) + +A provider forgets: a re-requested check suite shows only its latest +outcome, and retention removes old runs. Since 0.2.0 what one bundle proved +about a revision is carried into the next one (PV-HIST-002, accepted by +PV-REV-HIST-002, target B3), under the history-semantics lineage +`devostasis.ci-history.v2`. + +**The carrier** is the union of every attempt observed per parent per +revision: `{revision, committed_at, parents: [{parent_id, kind, +latest_attempt, attempts: [{attempt, state}]}], history_state, +historical_contribution, history_complete}`. Each bundle's union is its +predecessor's union plus what the provider shows now, so it holds every +history fact of the chain and selects none (the complete-chain-equivalent +reduction of section C). It lives in two canonical places: + +- `derived.revision_history` of the Integrity result in `snapshot.json`, + `{lineage, source, records}`: the durable output, present on every + evaluation path, `UNKNOWN` ones included, and whatever the bundle's + observations setting; only revisions that carry history are in it; +- `ci.revision_history_carried`, the observation a build adds before + evaluating, from its immediate predecessor: `AVAILABLE` with `{lineage, + records, source_bundle_id, source_observed_at, basis}`, `UNAVAILABLE / + NO_PREVIOUS_BUNDLE` for a first bundle, `UNKNOWN / HISTORY_GAP` when the + predecessor cannot be read or verified, `UNKNOWN / + PREDECESSOR_NOT_EARLIER` when it is not earlier, `UNKNOWN / + PREDECESSOR_CARRIES_NO_HISTORY` when it keeps neither a carrier nor its + observations. + +**The source** is the verified immediately previous bundle of the same +immutable project, the one the comparison resolves, never an older one: when +that bundle is missing, unverifiable or belongs to another project, nothing +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`). + +**Reconciliation**, per revision of the current 14-day inventory: the carried +union and the provider's current parents are merged by parent identity, and +the state is a function of the union alone. An observed `VERIFY_FAIL` is +`FAILURE_OBSERVED` from either side and absorbing (HIST-01, -02, -05, -09); +favorable evidence is `PASS_ONLY_OBSERVED` only when the union is complete; +a prior `UNKNOWN_HISTORY` does not heal from a later pass that is complete +only for what is visible (HIST-19), and heals when every attempt is observed. +Retries never add samples (HIST-03), a newer revision is a distinct one +(HIST-04), a revision that ages out of the window stops contributing +(HIST-10), and a revision the complete inventory no longer holds leaves it. +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). + +**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 +copied from the state they derived (HIST-13): the first 0.2.0 bundle of a +project carries the history of its 0.1.9 predecessor this way. Any other +lineage has neither a replay nor an accepted migration (HIST-14 has nothing +to execute): its revisions are `UNKNOWN_HISTORY` until complete current +evidence repairs them (HIST-15), and the old bundle keeps its record as +historical evidence. ## The newest revision @@ -120,7 +180,9 @@ R1 and R2 vectors, PV-REV-TEST-VECTORS-002). The strength is emitted in ## Conformance cases implemented R54 same-revision retry keeps historical failure; R55 retry-count invariance; -R56 newer revision is distinct; R57 order and surface invariance; the V0.7 +R56 newer revision is distinct; R57 order and surface invariance; HIST-01..20 +of PV-HIST-002 except HIST-14, which has no accepted migration to execute; +the V0.7 composition precedence; CI-OUTCOME-01..04 outcome mapping; unresolved current verification degrades instead of claiming `CLEAN`; INT-UNKNOWN-01..06; T2, R1 and R2 as vectors ([conformance.md](conformance.md)). The `ci` vector kind diff --git a/docs/spec/observations.md b/docs/spec/observations.md index f9bc4d6..52d23f9 100644 --- a/docs/spec/observations.md +++ b/docs/spec/observations.md @@ -73,6 +73,7 @@ Inventories (emitted by adapters): | `forge.releases.inventory` | series | the 30 most recent published releases; a full page is `PARTIAL`, because the provider had at least that many | | `ci.configured` | boolean | positively observed presence or absence of verification | | `ci.revision_verdicts_14d` | series | one canonical record per default-branch revision of the 14-day window (see [integrity-ci.md](integrity-ci.md)); coverage `runs_complete`, `attempts_complete`, `suites_complete`, `suite_revisions_planned`, `suite_revisions_examined`, `suites_stop_reason`, `surface` | +| `ci.revision_history_carried` | record | added by a build, not a collector: the durable revision history of the immediate predecessor bundle, or why there is none ([integrity-ci.md](integrity-ci.md#durable-history-pv-hist-002)) | Aggregates (derived deterministically from inventories; status follows the relevant coverage flag): diff --git a/docs/spec/vectors.md b/docs/spec/vectors.md index 309bcae..4098cbb 100644 --- a/docs/spec/vectors.md +++ b/docs/spec/vectors.md @@ -12,7 +12,7 @@ exact vectors of the research process are in the corpus (seven of those 70, with sixty-three still to come), beside the accepted families transcribed from their contracts (`ORDER`, `INT-UNKNOWN`, `INT-TOTAL`, `CLU-INCOMPLETE`, `ACT-COV`, and since 0.2.0 `DIR-CLOSED`, `DIR-INCOMPLETE`, `HOR-PARTIAL`, -`DEBT-PARTIAL`). Authored against this format a case arrives executable instead of +`DEBT-PARTIAL`, `HIST`). Authored against this format a case arrives executable instead of needing translation. ## A vector file diff --git a/docs/spec/vitals.md b/docs/spec/vitals.md index 844fd55..b1912c5 100644 --- a/docs/spec/vitals.md +++ b/docs/spec/vitals.md @@ -44,7 +44,7 @@ taxonomy and the policy constants. The seven rule ids of this version: | Clutter | `clutter.bands.v1` | 0.1.9 (the incomplete-evidence contract and its subset proof) | | Direction | `direction.bands.v2` | 0.2.0 (state-neutral and incomplete linkage, PV-REV-DIRECTION-CLOSED-TARGET-001 and PV-DIRECTION-INCOMPLETE-001) | | Flow | `flow.bands.v1` | 0.1.2 (the calibration repairs) | -| Integrity | `integrity.bands.v1+ci-unit-004` | 0.1.9 (the newest-UNKNOWN, partial-series, sample-strength and totality judgements) | +| Integrity | `integrity.bands.v1+ci-unit-004+hist-002` | 0.2.0 (durable revision history, PV-HIST-002; the band table and the 0.1.9 judgements unchanged) | | Debt | `debt.bands.v2` | 0.2.0 (partial register, PV-DEBT-PARTIAL-001) | | Pulse | `pulse.bands.v1` | 0.1.2 (the calibration repairs) | @@ -170,7 +170,29 @@ to three decisive revisions, `ESTABLISHED` from four, and the diagnostic band is exact about the revisions it counts and says so, so that `FAILING` over one revision is not read as an established rate. -Degradation and refusal, under rule `integrity.bands.v1+ci-unit-004`: +History is counted over the durable revision history of **PV-HIST-002** +(rule `integrity.bands.v1+ci-unit-004+hist-002`, HIST-01..20): every attempt +one bundle observed for a revision is carried into the next one while the +revision is in the window, so a failure the provider later hides behind a +passing retry, or forgets through retention, still counts, and exactly once. +Favorable evidence that cannot prove every attempt, a parent-level check suite +or an Actions run whose earlier attempts are not all observed, is +`UNKNOWN_HISTORY` rather than a reconstructed pass (R52). The carrier and its +rules are in [integrity-ci.md](integrity-ci.md#durable-history-pv-hist-002). + +Degradation and refusal, under rule `integrity.bands.v1+ci-unit-004+hist-002`: + +- a recent revision whose history is `UNKNOWN_HISTORY` makes the Vital + `UNKNOWN` with no band, each such revision named by + `REVISION_HISTORY_UNKNOWN:` and the counts of the others kept in + `derived`: T9 puts unknown history above every conservative branch, so no + band is derived around it; +- durable history that proves verification happened while the provider shows + no current verification at all is `UNKNOWN` + (`CURRENT_VERIFICATION_NOT_OBSERVED`): a current verdict is never carried + from an earlier bundle (HIST-16); +- carried history that is not the shape its lineage declares is corrupt + persisted history and `UNKNOWN` (`REVISION_HISTORY_CARRY_MALFORMED`); - a required revision series with acquisition status `PARTIAL` is `UNKNOWN` with no band (`PV-REV-TEST-003`): the accepted chain has no degraded path diff --git a/src/devostasis/adapters/github_ci.py b/src/devostasis/adapters/github_ci.py index d72f3da..d5430b2 100644 --- a/src/devostasis/adapters/github_ci.py +++ b/src/devostasis/adapters/github_ci.py @@ -6,7 +6,12 @@ * the greatest observed attempt governs a parent's current state; * history is failure-sticky: any observed VERIFY_FAIL for a revision keeps its historical contribution VERIFY_FAIL while the revision is in the window; -* attempts are never independent samples. +* attempts are never independent samples; +* history is known only where every attempt is: an Actions run whose attempts + cover 1..latest. A check suite keeps its id across re-requests and shows + only the latest outcome, so favorable parent-level or incomplete evidence is + ``UNKNOWN_HISTORY``, never a reconstructed pass (PV-CI-UNIT-004 R52, + PV-HIST-002 HIST-06). An observed failure is proven on any surface. """ from __future__ import annotations @@ -36,6 +41,8 @@ ATTEMPT_LEVEL = "ATTEMPT_LEVEL" PARENT_LEVEL_ONLY = "PARENT_LEVEL_ONLY" +ACTIONS_KIND = "github_actions_workflow_run" +UNKNOWN_HISTORY = "UNKNOWN_HISTORY" def normalize_outcome(status: str | None, conclusion: str | None) -> str: @@ -63,6 +70,15 @@ def history_state(states: list[str]) -> str: return "NO_DECISIVE_OBSERVED" +def known_history_state(states: list[str], complete: bool) -> str: + """The state evidence proves: a failure always, anything favorable only over complete attempts.""" + if VERIFY_FAIL in states: + return "FAILURE_OBSERVED" + if not complete: + return UNKNOWN_HISTORY + return history_state(states) + + def contribution(state: str) -> str | None: return {"FAILURE_OBSERVED": VERIFY_FAIL, "PASS_ONLY_OBSERVED": VERIFY_PASS}.get(state) @@ -77,6 +93,7 @@ def actions_parent(run: dict[str, Any], prior_attempts: list[dict[str, Any]]) -> attempts.sort(key=lambda a: a["attempt"]) current = attempts[-1]["state"] states = [a["state"] for a in attempts] + complete = len(attempts) == latest_attempt return { "parent_id": f"github_actions:workflow_run:{run['id']}", "kind": "github_actions_workflow_run", @@ -85,8 +102,8 @@ def actions_parent(run: dict[str, Any], prior_attempts: list[dict[str, Any]]) -> "current_attempt": latest_attempt, "current_state": current, "attempts_observed": attempts, - "attempts_complete": len(attempts) == latest_attempt, - "history_state": history_state(states), + "attempts_complete": complete, + "history_state": known_history_state(states, complete), "url": run.get("html_url"), } @@ -103,7 +120,7 @@ def check_suite_parent(suite: dict[str, Any]) -> dict[str, Any]: "current_state": state, "attempts_observed": [{"attempt": None, "state": state}], "attempts_complete": False, - "history_state": history_state([state]), + "history_state": known_history_state([state], False), "url": suite.get("url"), } @@ -118,10 +135,11 @@ def build_revision_records( parents = sorted(parents_by_sha.get(commit["sha"], []), key=lambda p: p["parent_id"]) current_states = [p["current_state"] for p in parents] all_states = [a["state"] for p in parents for a in p["attempts_observed"]] - history = history_state(all_states) if parents else "NO_DECISIVE_OBSERVED" + complete = all(p["kind"] == ACTIONS_KIND and p.get("attempts_complete") is True for p in parents) + history = known_history_state(all_states, complete) if parents else "NO_DECISIVE_OBSERVED" provenance = None if parents: - provenance = ATTEMPT_LEVEL if all(p["kind"] == "github_actions_workflow_run" for p in parents) else PARENT_LEVEL_ONLY + provenance = ATTEMPT_LEVEL if all(p["kind"] == ACTIONS_KIND for p in parents) else PARENT_LEVEL_ONLY records.append( { "revision": commit["sha"], @@ -131,6 +149,7 @@ def build_revision_records( "history_state": history, "historical_contribution": contribution(history), "history_provenance": provenance, + "history_complete": complete, } ) return records diff --git a/src/devostasis/bundle.py b/src/devostasis/bundle.py index aa2d689..298d06d 100644 --- a/src/devostasis/bundle.py +++ b/src/devostasis/bundle.py @@ -67,6 +67,7 @@ IDENTITY_FIELD_MISMATCH = "IDENTITY_FIELD_MISMATCH" RECEIPT_DIGEST_MISMATCH = "RECEIPT_DIGEST_MISMATCH" RECEIPT_COPY_MISMATCH = "RECEIPT_COPY_MISMATCH" +HISTORY_SOURCE_MISMATCH = "HISTORY_SOURCE_MISMATCH" OBSERVATIONS_DIGEST_MISMATCH = "OBSERVATIONS_DIGEST_MISMATCH" UNSUPPORTED_LINEAGE = "UNSUPPORTED_ARTIFACT_LINEAGE" RENDERER_NOT_IN_LINEAGE = "RENDERER_VERSION_NOT_IN_LINEAGE" @@ -452,6 +453,48 @@ def _check_evidence_binding(members: dict[str, bytes], manifest: dict[str, Any], declared = manifest["members"]["observations.json"] if isinstance(snapshot, dict) and snapshot.get("observations_digest") != declared: problems.append(f"{OBSERVATIONS_DIGEST_MISMATCH}: snapshot.json was evaluated over {snapshot.get('observations_digest')}, the bundle carries {declared}") + problems.extend(_check_history_source(members, manifest, observations)) + return problems + + +def _history_source_named(snapshot: Any) -> tuple[str | None, str | None]: + """The status and bundle of the durable history source the Integrity result names, if it names one.""" + if not isinstance(snapshot, dict): + return None, None + for vital in snapshot.get("vitals") or []: + if isinstance(vital, dict) and vital.get("vital_id") == "integrity": + history = (vital.get("derived") or {}).get("revision_history") if isinstance(vital.get("derived"), dict) else None + source = history.get("source") if isinstance(history, dict) else None + if isinstance(source, dict): + return source.get("status"), source.get("bundle_id") + return None, None + + +def _check_history_source(members: dict[str, bytes], manifest: dict[str, Any], observations: Any) -> list[str]: + """PV-HIST-002 section C: the carried history came from the bundle this one follows, and says so. + + The selection of the source is auditable only if the bundle names it; a + bundle whose carried history names another bundle than the one its + manifest says it follows consumed history from outside its own chain. + Bundles older than the carrier name nothing and are not checked. + """ + problems: list[str] = [] + previous = manifest.get("previous_bundle_id") + try: + snapshot = canonical.loads(members["snapshot.json"].decode("utf-8")) if "snapshot.json" in members else None + except Exception: # noqa: BLE001 + snapshot = None + status, source = _history_source_named(snapshot) + if status == "CARRIED" and source != previous: + problems.append(f"{HISTORY_SOURCE_MISMATCH}: the Integrity history was carried from {source}, the manifest follows {previous}") + if isinstance(observations, dict): + for item in observations.get("observations") or []: + if not isinstance(item, dict) or item.get("observation_id") != "ci.revision_history_carried": + continue + value = item.get("value") + named = value.get("source_bundle_id") if isinstance(value, dict) else None + if named is not None and named != previous: + problems.append(f"{HISTORY_SOURCE_MISMATCH}: observations.json carries history from {named}, the manifest follows {previous}") return problems diff --git a/src/devostasis/history.py b/src/devostasis/history.py index 15baa6b..5caf7d0 100644 --- a/src/devostasis/history.py +++ b/src/devostasis/history.py @@ -89,6 +89,10 @@ class LatestState: manifest: dict[str, Any] | None snapshot: dict[str, Any] | None problems: list[str] + # The observation set the bundle was evaluated over, when it keeps one: the + # replay source of durable revision history for a bundle older than the + # carrier in its snapshot (PV-HIST-002, HIST-13). + observations: dict[str, Any] | None = None @dataclass @@ -405,6 +409,7 @@ def latest(self, project_key: str, immutable_project_id: str | None = None) -> L try: manifest = canonical.loads(members["manifest.json"].decode("utf-8")) snapshot = canonical.loads(members["snapshot.json"].decode("utf-8")) if "snapshot.json" in members else None + observations = canonical.loads(members["observations.json"].decode("utf-8")) if "observations.json" in members else None except Exception as exc: # noqa: BLE001 return LatestState(True, False, expected_id, None, None, problems + [f"immutable bundle unreadable: {type(exc).__name__}"]) if not isinstance(manifest, dict): @@ -423,7 +428,7 @@ def latest(self, project_key: str, immutable_project_id: str | None = None) -> L copied_id = None if copied_id is not None and copied_id != manifest.get("bundle_id") and not self._is_stale_copy(index, copied_id, manifest): problems.append(f"latest pointer {copied_id} differs from index tail {manifest.get('bundle_id')}") - return LatestState(True, not problems, manifest.get("bundle_id"), manifest, snapshot, problems) + return LatestState(True, not problems, manifest.get("bundle_id"), manifest, snapshot, problems, observations if isinstance(observations, dict) else None) @staticmethod def _is_stale_copy(index: dict[str, Any], copied_id: str, tail_manifest: dict[str, Any]) -> bool: diff --git a/src/devostasis/revision_history.py b/src/devostasis/revision_history.py new file mode 100644 index 0000000..dbbddb3 --- /dev/null +++ b/src/devostasis/revision_history.py @@ -0,0 +1,393 @@ +"""Durable Integrity revision history across bundles (PV-HIST-002, accepted by PV-REV-HIST-002). + +A provider forgets: a re-run hides the attempt that failed behind the one that +passed on a parent-level surface, and retention removes old runs altogether. +Integrity's history is a set property of one immutable revision +(``ANY_FAIL_ELSE_ANY_PASS_PER_IMMUTABLE_REVISION``, PV-CI-UNIT-004), so what +one bundle proved about a revision must survive into the next one while the +revision is in the 14-day window. + +The carrier is the union of every attempt observed per parent per revision. +Each bundle's union is the previous bundle's union plus what the provider +shows now, which makes it the complete-chain-equivalent reduction section C +of the contract allows: it holds every history fact of the intervening chain +and selects nothing. It is carried in two canonical places: + +* ``derived.revision_history`` of the Integrity result in ``snapshot.json``, + the durable output, present in every bundle whatever its observations + setting; +* ``ci.revision_history_carried``, the input observation a build adds from its + immediate predecessor before evaluating, so the bundle names the source it + consumed and a replay of its observations reproduces its evaluation. + +The state of a revision is a function of its union alone: + +* a ``VERIFY_FAIL`` attempt anywhere is ``FAILURE_OBSERVED``, absorbing, from + current evidence or durable history alike (HIST-01, -02, -05, -09); +* otherwise the history is known only when every parent's attempts are + complete: an Actions run whose attempts cover 1..latest. A check suite never + is, because a re-request keeps its id and hides the earlier outcome, so + favorable parent-level or incomplete evidence is ``UNKNOWN_HISTORY``, never + a reconstructed pass (R52, HIST-06, HIST-19); +* then ``PASS_ONLY_OBSERVED`` or ``NO_DECISIVE_OBSERVED``. + +A revision whose prior history could not be carried under the current +lineage stays ``UNKNOWN_HISTORY`` until complete current evidence repairs it +(HIST-15). Current verdicts are never carried: they belong to the current +observation (HIST-16). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from . import timeutil +from .observations import AVAILABLE, FRESH, PARTIAL, UNAVAILABLE, UNKNOWN, Observation, ObservationSet +from .policy import INTEGRITY + +# The history-semantics lineage of this implementation: provider outcome +# normalization (devostasis.ci-outcomes.github.v1), parent and attempt +# identity, the union reduction and the PV-CI-UNIT-004 state mapping with +# R52's completeness rule. Only a change to one of those moves it (section 5). +LINEAGE = "devostasis.ci-history.v2" +# The revision records 0.1.x bundles carry in ci.revision_verdicts_14d: the +# same outcome map and parent identity with every observed attempt kept, so a +# union can be replayed from them deterministically (HIST-13). +REPLAYABLE_LINEAGE = "devostasis.ci-history.v1" + +CARRIED = "ci.revision_history_carried" +REVISIONS = "ci.revision_verdicts_14d" + +FAILURE_OBSERVED = "FAILURE_OBSERVED" +PASS_ONLY_OBSERVED = "PASS_ONLY_OBSERVED" +NO_DECISIVE_OBSERVED = "NO_DECISIVE_OBSERVED" +UNKNOWN_HISTORY = "UNKNOWN_HISTORY" +STATES = (FAILURE_OBSERVED, PASS_ONLY_OBSERVED, NO_DECISIVE_OBSERVED, UNKNOWN_HISTORY) +CONTRIBUTION = {FAILURE_OBSERVED: "VERIFY_FAIL", PASS_ONLY_OBSERVED: "VERIFY_PASS", NO_DECISIVE_OBSERVED: None, UNKNOWN_HISTORY: None} + +ACTIONS_KIND = "github_actions_workflow_run" + +# Where the carried history came from. +SOURCE_CARRIED = "CARRIED" +SOURCE_BASELINE = "BASELINE" +SOURCE_GAP = "HISTORY_GAP" +SOURCE_NONE = "NOT_STATED" +SOURCE_MALFORMED = "MALFORMED" +BASIS_SNAPSHOT = "SNAPSHOT" +BASIS_SERIES = "REPLAYED_SERIES" + +NO_PREVIOUS_BUNDLE = "NO_PREVIOUS_BUNDLE" +PREDECESSOR_NOT_EARLIER = "PREDECESSOR_NOT_EARLIER" +PREDECESSOR_CARRIES_NO_HISTORY = "PREDECESSOR_CARRIES_NO_HISTORY" +HISTORY_GAP = "HISTORY_GAP" + + +class HistoryShapeError(ValueError): + """Carried history that is not the shape its lineage declares: corrupt persisted history.""" + + +# --------------------------------------------------------------------------- the union + + +def _attempt_key(attempt: dict[str, Any]) -> tuple[int, int, str]: + number = attempt.get("attempt") + return (0 if number is None else 1, number if isinstance(number, int) else 0, str(attempt.get("state"))) + + +def _attempts(raw: Any, where: str) -> list[dict[str, Any]]: + if not isinstance(raw, list): + raise HistoryShapeError(f"{where}: attempts is not a list") + seen: dict[tuple[Any, str], 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") + 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") + seen[(number, attempt["state"])] = {"attempt": number, "state": attempt["state"]} + return sorted(seen.values(), key=_attempt_key) + + +def _parent(parent_id: Any, kind: Any, latest: Any, attempts: Any, where: str) -> dict[str, Any]: + if not isinstance(parent_id, str) or not parent_id: + raise HistoryShapeError(f"{where}: a parent carries no id") + if not isinstance(kind, str) or not kind: + raise HistoryShapeError(f"{where}: parent {parent_id} carries no kind") + if latest is not None and (isinstance(latest, bool) or not isinstance(latest, int) or latest < 1): + raise HistoryShapeError(f"{where}: parent {parent_id} latest attempt {latest!r} is not a positive integer") + return {"parent_id": parent_id, "kind": kind, "latest_attempt": latest, "attempts": _attempts(attempts, f"{where} parent {parent_id}")} + + +def parent_from_current(parent: Any, where: str) -> dict[str, Any]: + """A parent of a current revision record (github_ci shape) as durable evidence.""" + if not isinstance(parent, dict): + raise HistoryShapeError(f"{where}: a parent is not an object") + return _parent(parent.get("parent_id"), parent.get("kind"), parent.get("current_attempt"), parent.get("attempts_observed"), where) + + +def parent_from_durable(parent: Any, where: str) -> dict[str, Any]: + """A parent of a carried union record.""" + if not isinstance(parent, dict): + raise HistoryShapeError(f"{where}: a parent is not an object") + return _parent(parent.get("parent_id"), parent.get("kind"), parent.get("latest_attempt"), parent.get("attempts"), where) + + +def merge_parents(*groups: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[str]]: + """The union of parents by identity: every attempt either side observed, the greatest attempt number either saw.""" + merged: dict[str, dict[str, Any]] = {} + conflicts: list[str] = [] + for group in groups: + for parent in group: + known = merged.get(parent["parent_id"]) + if known is None: + merged[parent["parent_id"]] = {**parent, "attempts": list(parent["attempts"])} + continue + if known["kind"] != parent["kind"]: + conflicts.append(f"PARENT_KIND_CONFLICT:{parent['parent_id']}") + numbers = [n for n in (known["latest_attempt"], parent["latest_attempt"]) if n is not None] + known["latest_attempt"] = max(numbers) if numbers else None + known["attempts"] = _attempts(known["attempts"] + parent["attempts"], parent["parent_id"]) + return [merged[key] for key in sorted(merged)], sorted(set(conflicts)) + + +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: + return False + numbers = {a["attempt"] for a in parent["attempts"] if isinstance(a.get("attempt"), int)} + return set(range(1, latest + 1)) <= numbers + + +def union_state(parents: list[dict[str, Any]], unresolved: list[str]) -> tuple[str, bool]: + """The PV-CI-UNIT-004 state of a revision's union, and whether its history is complete.""" + states = [a["state"] for p in parents for a in p["attempts"]] + complete = not unresolved and all(parent_complete(p) for p in parents) + if "VERIFY_FAIL" in states: + return FAILURE_OBSERVED, complete + if not complete: + return UNKNOWN_HISTORY, False + if "VERIFY_PASS" in states: + return PASS_ONLY_OBSERVED, True + return NO_DECISIVE_OBSERVED, True + + +def union_record(revision: str, committed_at: str, parents: list[dict[str, Any]], unresolved: list[str]) -> dict[str, Any]: + state, complete = union_state(parents, unresolved) + record: dict[str, Any] = { + "revision": revision, + "committed_at": committed_at, + "parents": parents, + "history_state": state, + "historical_contribution": CONTRIBUTION[state], + "history_complete": complete, + } + if unresolved: + record["unresolved"] = sorted(set(unresolved)) + return record + + +def current_history(parents: list[dict[str, Any]]) -> tuple[str, bool]: + """The state current evidence proves on its own (no durable history), for the per-bundle revision record.""" + durable = [parent_from_current(p, "current") for p in parents] + return union_state(durable, []) + + +# --------------------------------------------------------------------------- reading carried history + + +def _carried_records(value: Any) -> tuple[list[dict[str, Any]], dict[str, Any]]: + """The carried union records, replayed or marked unresolved according to their lineage.""" + if not isinstance(value, dict): + raise HistoryShapeError("the carried history is not an object") + lineage = value.get("lineage") + raw = value.get("records") + if not isinstance(lineage, str) or not lineage or not isinstance(raw, list): + raise HistoryShapeError("the carried history names no lineage or no records") + source = { + "lineage": lineage, + "bundle_id": value.get("source_bundle_id"), + "observed_at": value.get("source_observed_at"), + "basis": value.get("basis"), + } + records: list[dict[str, Any]] = [] + 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): + raise HistoryShapeError(f"{where}: no revision or committed_at") + timeutil.parse_ts(record["committed_at"]) + if lineage == LINEAGE: + parents = [parent_from_durable(p, where) for p in record.get("parents") or []] + unresolved = record.get("unresolved") or [] + if not isinstance(unresolved, list) or not all(isinstance(reason, str) for reason in unresolved): + raise HistoryShapeError(f"{where}: unresolved is not a list of reasons") + records.append(union_record(record["revision"], record["committed_at"], merge_parents(parents)[0], unresolved)) + elif lineage == REPLAYABLE_LINEAGE: + # HIST-13: replayed from the canonical attempts the older record kept, + # never copied from the state it derived under the older semantics. + parents = [parent_from_current(p, where) for p in record.get("parents") or []] + records.append(union_record(record["revision"], record["committed_at"], merge_parents(parents)[0], [])) + else: + # HIST-15: no replay and no accepted migration from this lineage; the + # old record stays historical evidence in its own bundle, and the + # revision's history is unknown until current evidence repairs it. + records.append(union_record(record["revision"], record["committed_at"], [], [f"LINEAGE_INCOMPATIBLE:{lineage}"])) + return records, source + + +# --------------------------------------------------------------------------- reconciliation + + +@dataclass +class Reconciled: + """The reconciled history of one evaluation.""" + + records: dict[str, dict[str, Any]] + source: dict[str, Any] + problems: list[str] = field(default_factory=list) + + def output(self) -> dict[str, Any]: + """The durable carrier the next bundle consumes: ``derived.revision_history``. + + Only revisions that carry history are in it: a revision without a + verification parent and without an unresolved prior adds nothing a + later bundle could need. + """ + bearing = [r for r in self.records.values() if r["parents"] or r.get("unresolved")] + ordered = sorted(bearing, key=lambda r: (r["committed_at"], r["revision"])) + return {"lineage": LINEAGE, "source": dict(self.source), "records": ordered} + + +def _series_records(series: Observation | None) -> tuple[list[dict[str, Any]] | None, bool]: + """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 + + +def reconcile(obs: ObservationSet) -> Reconciled: + """Combine the carried union with what the provider shows now (section D of the contract).""" + observed_at = timeutil.parse_ts(obs.observed_at) + window_start = timeutil.minus_days(observed_at, INTEGRITY["window_days"]) + carried_obs = obs.get(CARRIED) + carried: list[dict[str, Any]] = [] + problems: list[str] = [] + if carried_obs is None: + source: dict[str, Any] = {"status": SOURCE_NONE} + elif carried_obs.status == UNAVAILABLE and carried_obs.reason_code == NO_PREVIOUS_BUNDLE: + source = {"status": SOURCE_BASELINE, "reason": NO_PREVIOUS_BUNDLE} + elif carried_obs.status != AVAILABLE or carried_obs.freshness != FRESH: + # HIST-08: a required predecessor that is missing, unverifiable or not + # earlier is a gap; nothing is carried across it and nothing older is + # chosen instead (HIST-20). + source = {"status": SOURCE_GAP, "reason": carried_obs.reason_code or carried_obs.status} + if isinstance(carried_obs.evidence_ref, dict) and carried_obs.evidence_ref.get("previous_bundle_id"): + source["bundle_id"] = carried_obs.evidence_ref["previous_bundle_id"] + else: + try: + carried, detail = _carried_records(carried_obs.value) + source = {"status": SOURCE_CARRIED, **detail} + except (HistoryShapeError, ValueError, TypeError) as exc: + source = {"status": SOURCE_MALFORMED} + problems.append(f"REVISION_HISTORY_CARRY_MALFORMED:{exc}") + + current, inventory_complete = _series_records(obs.get(REVISIONS)) + current_by_rev = {r["revision"]: r for r in current or []} + records: dict[str, dict[str, Any]] = {} + + for prior in carried: + if timeutil.parse_ts(prior["committed_at"]) < window_start: + continue # HIST-10: aged out of the 14-day window + if inventory_complete and prior["revision"] not in current_by_rev: + continue # the complete inventory no longer holds it: off the default branch + records[prior["revision"]] = prior + + 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 + 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): + # HIST-15, HIST-19: complete current evidence repairs a prior that + # could not be carried; anything less leaves the revision unknown. + unresolved = [] + parents, conflicts = merge_parents(prior_parents, visible) + records[revision] = union_record(revision, committed_at, parents, unresolved + conflicts) + return Reconciled(records=records, source=source, problems=problems) + + +# --------------------------------------------------------------------------- the carried observation + + +def carried_observation( + comparison_status: str, + previous_bundle_id: str | None, + previous_manifest: dict[str, Any] | None, + previous_snapshot: dict[str, Any] | None, + previous_observations: dict[str, Any] | None, + reasons: list[str], + observed_at: str, +) -> Observation: + """The history a build consumes: its immediate predecessor's union, or why there is none. + + Section C: the source is the verified immediately previous successful + bundle of the same immutable project, the one the comparison already + resolved; when it is missing, unverifiable or not earlier, the answer is a + gap, never an older bundle. + """ + common = { + "provider": "history", + "collected_at": observed_at, + "adapter_version": LINEAGE, + "source_ref": f"bundle:{previous_bundle_id}" if previous_bundle_id else "store:none", + } + if comparison_status == "BASELINE" or previous_bundle_id is None: + return Observation(observation_id=CARRIED, status=UNAVAILABLE, value_type="record", reason_code=NO_PREVIOUS_BUNDLE, **common) + evidence = {"previous_bundle_id": previous_bundle_id} + if comparison_status == "HISTORY_GAP" or previous_manifest is None or previous_snapshot is None: + return Observation(observation_id=CARRIED, status=UNKNOWN, value_type="record", reason_code=HISTORY_GAP, evidence_ref=evidence, **common) + if any(str(reason).startswith("NON_MONOTONIC_OBSERVATION") for reason in reasons): + return Observation(observation_id=CARRIED, status=UNKNOWN, value_type="record", reason_code=PREDECESSOR_NOT_EARLIER, evidence_ref=evidence, **common) + source = {"source_bundle_id": previous_bundle_id, "source_observed_at": previous_manifest.get("observed_at")} + integrity = next((v for v in previous_snapshot.get("vitals") or [] if isinstance(v, dict) and v.get("vital_id") == "integrity"), None) + durable = (integrity or {}).get("derived", {}).get("revision_history") if isinstance((integrity or {}).get("derived"), dict) else None + if isinstance(durable, dict) and "lineage" in durable: + value = {"lineage": durable.get("lineage"), "records": durable.get("records"), "basis": BASIS_SNAPSHOT, **source} + return Observation(observation_id=CARRIED, status=AVAILABLE, value_type="record", value=value, evidence_ref=evidence, **common) + series = None + if isinstance(previous_observations, dict): + series = next((o for o in previous_observations.get("observations") or [] if isinstance(o, dict) and o.get("observation_id") == REVISIONS), None) + if series is not None: + # A bundle older than this carrier: replay its revision records. + records = series.get("value") if series.get("status") in (AVAILABLE, PARTIAL) and isinstance(series.get("value"), list) else [] + value = {"lineage": REPLAYABLE_LINEAGE, "records": records, "basis": BASIS_SERIES, "source_series_status": series.get("status"), **source} + return Observation(observation_id=CARRIED, status=AVAILABLE, value_type="record", value=value, evidence_ref=evidence, **common) + return Observation(observation_id=CARRIED, status=UNKNOWN, value_type="record", reason_code=PREDECESSOR_CARRIES_NO_HISTORY, evidence_ref=evidence, **common) + + +def with_carried_history(obs: ObservationSet, carried: Observation) -> ObservationSet: + """A copy of ``obs`` with the carried history added; the caller's set is left as it was.""" + copy = ObservationSet(subject=obs.subject, observed_at=obs.observed_at, observations=obs.sorted(), receipt=obs.receipt) + copy.add(carried) + return copy + + +def carried_source_bundle(obs: ObservationSet) -> str | None: + """The bundle a stated carried history came from, when it names one.""" + carried = obs.get(CARRIED) + if carried is None: + return None + if isinstance(carried.value, dict) and carried.value.get("source_bundle_id"): + return str(carried.value["source_bundle_id"]) + if isinstance(carried.evidence_ref, dict) and carried.evidence_ref.get("previous_bundle_id"): + return str(carried.evidence_ref["previous_bundle_id"]) + return None diff --git a/src/devostasis/runner.py b/src/devostasis/runner.py index e422f77..b81684f 100644 --- a/src/devostasis/runner.py +++ b/src/devostasis/runner.py @@ -10,7 +10,7 @@ from . import activity as activity_mod from . import delta as delta_mod -from . import canonical, fleet, normalize, render, timeutil +from . import canonical, fleet, normalize, render, revision_history, timeutil from .adapters.cache import ConditionalCache from .adapters.github import CollectionError, GitHubAdapter, GitHubClient, UrllibTransport from .bundle import Bundle, BundleError, build_bundle @@ -52,13 +52,36 @@ def evaluate(obs: ObservationSet, project: ResolvedProject | None = None) -> dic return build_snapshot(obs, evaluate_all(obs)) +@dataclass +class Predecessor: + """The bundle a new one follows: what the comparison and the carried history both consume.""" + + status: str + bundle_id: str | None + manifest: dict[str, Any] | None + snapshot: dict[str, Any] | None + observations: dict[str, Any] | None + reasons: list[str] + + def decide_comparison( store: FilesystemHistoryStore, project: ResolvedProject, immutable_project_id: str | None = None, observed_at: str | None = None, ) -> tuple[str, str | None, dict[str, Any] | None, dict[str, Any] | None, list[str]]: - """Return (comparison_status, previous_bundle_id, previous_manifest, previous_snapshot, reasons). + """Return (comparison_status, previous_bundle_id, previous_manifest, previous_snapshot, reasons).""" + found = find_predecessor(store, project, immutable_project_id, observed_at) + return found.status, found.bundle_id, found.manifest, found.snapshot, found.reasons + + +def find_predecessor( + store: FilesystemHistoryStore, + project: ResolvedProject, + immutable_project_id: str | None = None, + observed_at: str | None = None, +) -> Predecessor: + """The immediate predecessor of a new bundle and how the two compare. The immutable project id locates history across a rename or transfer (RPT-7); without it the locator is the identity and a renamed project @@ -74,9 +97,9 @@ def decide_comparison( """ latest = store.latest(project.project_key, immutable_project_id) if not latest.exists: - return delta_mod.BASELINE, None, None, None, [] + return Predecessor(delta_mod.BASELINE, None, None, None, None, []) if not latest.verified or latest.manifest is None or latest.snapshot is None: - return delta_mod.HISTORY_GAP, latest.bundle_id, None, None, latest.problems + return Predecessor(delta_mod.HISTORY_GAP, latest.bundle_id, None, None, None, latest.problems) current_fields = { "vitals_contract_version": VITALS_CONTRACT_VERSION, "observation_contract_version": OBSERVATION_CONTRACT_VERSION, @@ -89,8 +112,8 @@ def decide_comparison( if timeutil.parse_ts(observed_at) <= timeutil.parse_ts(previous_observed_at): reasons.append(f"{NON_MONOTONIC_OBSERVATION}:{previous_observed_at}->{observed_at}") if reasons: - return delta_mod.INCOMPARABLE, latest.bundle_id, latest.manifest, latest.snapshot, reasons - return delta_mod.COMPARABLE, latest.bundle_id, latest.manifest, latest.snapshot, [] + return Predecessor(delta_mod.INCOMPARABLE, latest.bundle_id, latest.manifest, latest.snapshot, latest.observations, reasons) + return Predecessor(delta_mod.COMPARABLE, latest.bundle_id, latest.manifest, latest.snapshot, latest.observations, []) def check_receipt_config(project: ResolvedProject, obs: ObservationSet) -> None: @@ -114,11 +137,46 @@ def check_receipt_config(project: ResolvedProject, obs: ObservationSet) -> None: ) +HISTORY_SOURCE_NOT_PREDECESSOR = "HISTORY_SOURCE_NOT_PREDECESSOR" + + +def attach_revision_history(obs: ObservationSet, predecessor: Predecessor) -> ObservationSet: + """The observations with the durable revision history the predecessor carries (PV-HIST-002, section C). + + 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. + """ + carried = revision_history.carried_observation( + predecessor.status, + predecessor.bundle_id, + predecessor.manifest, + predecessor.snapshot, + predecessor.observations, + predecessor.reasons, + obs.observed_at, + ) + stated = obs.get(revision_history.CARRIED) + if stated is None: + return revision_history.with_carried_history(obs, carried) + if revision_history.carried_source_bundle(obs) != predecessor.bundle_id or stated.status != carried.status: + raise BundleError( + f"{HISTORY_SOURCE_NOT_PREDECESSOR}: the observations carry revision history from " + 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" + ) + return obs + + def build_from_observations(project: ResolvedProject, obs: ObservationSet, store: FilesystemHistoryStore, run_meta: dict[str, Any] | None = None) -> Bundle: check_receipt_config(project, obs) + predecessor = find_predecessor(store, project, obs.subject.get("immutable_project_id"), obs.observed_at) + obs = attach_revision_history(obs, predecessor) snapshot = build_snapshot(obs, evaluate_all(obs)) - status, previous_id, previous_manifest, previous_snapshot, reasons = decide_comparison(store, project, obs.subject.get("immutable_project_id"), obs.observed_at) - delta = delta_mod.compare(snapshot, previous_snapshot, status, previous_id, reasons) + status, previous_id, previous_manifest, reasons = predecessor.status, predecessor.bundle_id, predecessor.manifest, predecessor.reasons + delta = delta_mod.compare(snapshot, predecessor.snapshot, status, previous_id, reasons) activity = None if project.activity_enabled: interval_start = previous_manifest.get("observed_at") if (status == delta_mod.COMPARABLE and previous_manifest) else None diff --git a/src/devostasis/vitals/integrity.py b/src/devostasis/vitals/integrity.py index 6772a24..f01d2bb 100644 --- a/src/devostasis/vitals/integrity.py +++ b/src/devostasis/vitals/integrity.py @@ -36,6 +36,19 @@ A required series that cannot be used is ``UNKNOWN`` whatever ``ci.configured`` says, because "no verification evidence" is exactly what it cannot establish, and a verdict outside the canonical vocabulary is read as ``UNKNOWN``. + +Rule ``integrity.bands.v1+ci-unit-004+hist-002`` (0.2.0) counts history over +the durable union of **PV-HIST-002** (accepted by PV-REV-HIST-002, HIST-01..20) +instead of over what the provider still shows: a failure one bundle proved +stays in the counts while its revision is in the window, whatever the provider +shows later, and favorable evidence that cannot prove every attempt +(parent-level check suites, incomplete attempts) is ``UNKNOWN_HISTORY``, never +a pass (R52). Any revision whose history is unknown makes the Vital +``UNKNOWN``: T9 puts unknown history above every conservative branch, so no +band is derived around it. The current verdict is still the current +observation's alone (HIST-16), and the reconciled union is emitted in +``derived.revision_history`` on every path, so the next bundle can carry it +even across an evaluation that claimed no band (see ``revision_history``). """ from __future__ import annotations @@ -45,6 +58,7 @@ from ..canonical import ratio from ..observations import AVAILABLE, FRESH, PARTIAL, ObservationSet from ..policy import INTEGRITY +from ..revision_history import CARRIED, SOURCE_GAP, UNKNOWN_HISTORY, Reconciled, reconcile from .common import ( EVAL_AVAILABLE, EVAL_DEGRADED, @@ -52,12 +66,12 @@ SEM_EXACT, VitalResult, input_meta, - unknown_result, ) VITAL_ID = "integrity" VITAL_VERSION = "PV-VITALS-V1-002/integrity" -RULE_ID = "integrity.bands.v1+ci-unit-004" +RULE_ID = "integrity.bands.v1+ci-unit-004+hist-002" +HISTORY_RULE = "PV-HIST-002" UNKNOWN_RULE = "PV-REV-INTEGRITY-UNKNOWN-001" PARTIAL_RULE = "PV-REV-TEST-003" SPARSE_RULE = "PV-REV-TEST-VECTORS-002" @@ -68,7 +82,7 @@ CONFIGURED = "ci.configured" REVISIONS = "ci.revision_verdicts_14d" -IDS = [CONFIGURED, REVISIONS] +IDS = [CONFIGURED, REVISIONS, CARRIED] SHARED = ["CI_VERIFICATION"] GROUPS = ["INTEGRITY_ONLY"] @@ -143,7 +157,7 @@ def sample_strength(n: int) -> str | None: return SAMPLE_SPARSE if n < INTEGRITY["established_sample"] else SAMPLE_ESTABLISHED -HISTORY_CONTRIBUTION = {"FAILURE_OBSERVED": VERIFY_FAIL, "PASS_ONLY_OBSERVED": VERIFY_PASS, "NO_DECISIVE_OBSERVED": None} +HISTORY_CONTRIBUTION = {"FAILURE_OBSERVED": VERIFY_FAIL, "PASS_ONLY_OBSERVED": VERIFY_PASS, "NO_DECISIVE_OBSERVED": None, UNKNOWN_HISTORY: None} def _record_consistent(record: Any) -> bool: @@ -167,8 +181,24 @@ def established_failing(n: int, f: int) -> bool: return n >= INTEGRITY["established_sample"] and f * fail_den >= n * fail_num +def _unknown(obs: ObservationSet, history: dict[str, Any], diagnostics: list[str], explanation: str = "Evidence is insufficient for a deterministic band; no band is fabricated.") -> VitalResult: + """UNKNOWN with no band, still carrying the durable history for the next bundle.""" + return _result(obs, None, EVAL_UNKNOWN, None, None, dict(history), diagnostics, explanation) + + +def _bearing(history: Reconciled, revisions: list[dict[str, Any]]) -> list[dict[str, Any]]: + """The reconciled records of the current inventory that carry any verification history.""" + records = [history.records[r["revision"]] for r in revisions if r["revision"] in history.records] + return [r for r in records if r["parents"] or r.get("unresolved")] + + def evaluate(obs: ObservationSet) -> VitalResult: diagnostics: list[str] = [] + reconciled = reconcile(obs) + history = {"revision_history": reconciled.output()} + if reconciled.source["status"] == SOURCE_GAP: + # HIST-08: explicit, and nothing older is carried in its place. + diagnostics.append(f"REVISION_HISTORY_GAP:{reconciled.source.get('reason')}") configured_obs = obs.get(CONFIGURED) series = obs.get(REVISIONS) configured_known = configured_obs is not None and configured_obs.good @@ -188,19 +218,40 @@ def evaluate(obs: ObservationSet) -> VitalResult: diagnostics.append(f"MISSING_REQUIRED:{REVISIONS}:{obs.status_of(REVISIONS)}/{obs.freshness_of(REVISIONS)}") if not configured_known: diagnostics.append(f"MISSING_REQUIRED:{CONFIGURED}:{obs.status_of(CONFIGURED)}/{obs.freshness_of(CONFIGURED)}") - return unknown_result(VITAL_ID, VITAL_VERSION, RULE_ID, obs, IDS, diagnostics, SHARED, GROUPS) + return _unknown(obs, history, diagnostics) revisions: list[dict[str, Any]] = list(series.value) inconsistent = [r for r in revisions if not _record_consistent(r)] - if inconsistent: + if inconsistent or reconciled.problems: # A record whose contribution contradicts its own history (or names a - # state outside the vocabulary) is a coverage defect, not a pass. + # state outside the vocabulary), or carried history that is not the + # shape its lineage declares, is a coverage defect, not a pass. diagnostics.extend(f"REVISION_RECORD_INCONSISTENT:{_record_label(r)}" for r in inconsistent) - return unknown_result(VITAL_ID, VITAL_VERSION, RULE_ID, obs, IDS, diagnostics, SHARED, GROUPS) + diagnostics.extend(reconciled.problems) + return _unknown(obs, history, diagnostics) active = [r for r in revisions if r.get("parents")] - decisive = [r for r in active if r.get("historical_contribution") in DECISIVE] + provenance: dict[str, int] = {} + for r in active: + key = r.get("history_provenance") or "UNKNOWN" + provenance[key] = provenance.get(key, 0) + 1 + if provenance.get("PARENT_LEVEL_ONLY"): + # Parent-level surfaces cannot prove an earlier failure; since R52 a + # favorable one is unknown history, and this names the surface. + diagnostics.append(f"HISTORY_PROVENANCE_PARENT_LEVEL_ONLY:{provenance['PARENT_LEVEL_ONLY']}") + bearing = _bearing(reconciled, revisions) + decisive = [r for r in bearing if r.get("historical_contribution") in DECISIVE] failed = [r for r in decisive if r.get("history_state") == "FAILURE_OBSERVED"] + unknown_history = [r for r in bearing if r.get("history_state") == UNKNOWN_HISTORY] n, f = len(decisive), len(failed) + counts: dict[str, Any] = { + "revisions_in_window": len(revisions), + "revisions_with_verification": len(active), + "revisions_with_history": len(bearing), + "decisive_count_14d": n, + "failed_count_14d": f, + "unknown_history_count_14d": len(unknown_history), + "history_rule": HISTORY_RULE, + } if series.status == PARTIAL: # PV-REV-TEST-003: a truncated required series has no accepted degraded @@ -214,19 +265,29 @@ def evaluate(obs: ObservationSet) -> VitalResult: EVAL_UNKNOWN, None, None, - { - "series_status": PARTIAL, - "revisions_in_window": len(revisions), - "revisions_with_verification": len(active), - "decisive_count_14d": n, - "failed_count_14d": f, - "partial_series_rule": PARTIAL_RULE, - }, + {**counts, "series_status": PARTIAL, "partial_series_rule": PARTIAL_RULE, **history}, diagnostics, f"The revision series is incomplete ({series.reason_code or 'truncated'}); {n} decisive revisions were observed, and no band is claimed over evidence known to be short.", ) + if unknown_history: + # 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) + return _result( + obs, None, EVAL_UNKNOWN, None, None, {**counts, **history}, 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.", + ) + if not active: + if bearing: + # Durable history proves verification happened, but nothing current + # was observed, and a current verdict is never carried (HIST-16). + diagnostics.append("CURRENT_VERIFICATION_NOT_OBSERVED") + return _result( + obs, None, EVAL_UNKNOWN, None, None, {**counts, **history}, diagnostics, + f"{len(bearing)} recent revisions carry verification history from earlier bundles, but the provider shows no current verification, so no current band is claimed.", + ) if configured_known and configured is False: return _result( obs, @@ -234,20 +295,20 @@ def evaluate(obs: ObservationSet) -> VitalResult: EVAL_AVAILABLE, SEM_EXACT, None, - {"decisive_count_14d": 0, "failed_count_14d": 0, "revisions_in_window": len(revisions), "revisions_with_verification": 0}, + {**counts, **history}, diagnostics, "No automated verification is configured and no verification evidence exists for recent revisions.", ) if not configured_known: diagnostics.append(f"MISSING_REQUIRED:{CONFIGURED}:{obs.status_of(CONFIGURED)}/{obs.freshness_of(CONFIGURED)}") - return unknown_result(VITAL_ID, VITAL_VERSION, RULE_ID, obs, IDS, diagnostics, SHARED, GROUPS) + return _unknown(obs, history, diagnostics) return _result( obs, "NO_RECENT_RUNS", EVAL_AVAILABLE, SEM_EXACT, None, - {"decisive_count_14d": 0, "failed_count_14d": 0, "revisions_in_window": len(revisions), "revisions_with_verification": 0}, + {**counts, **history}, diagnostics, f"Verification is configured but none of the {len(revisions)} recent default-branch revisions has a verification execution.", ) @@ -264,30 +325,21 @@ def evaluate(obs: ObservationSet) -> VitalResult: diagnostics.append(f"CURRENT_VERDICT_UNRECOGNIZED:{current}") current = VERDICT_UNKNOWN - provenance: dict[str, int] = {} - for r in active: - key = r.get("history_provenance") or "UNKNOWN" - provenance[key] = provenance.get(key, 0) + 1 - if provenance.get("PARENT_LEVEL_ONLY"): - diagnostics.append(f"HISTORY_PROVENANCE_PARENT_LEVEL_ONLY:{provenance['PARENT_LEVEL_ONLY']}") - strength = sample_strength(n) if strength == SAMPLE_SPARSE: diagnostics.append(SPARSE_DIAGNOSTIC) derived: dict[str, Any] = { - "revisions_in_window": len(revisions), - "revisions_with_verification": len(active), - "decisive_count_14d": n, - "failed_count_14d": f, + **counts, "failure_ratio_14d": ratio(f, n) if n else None, "current_revision": { "revision": latest.get("revision"), "committed_at": latest.get("committed_at"), "current_verdict": current, - "history_state": latest.get("history_state"), + "history_state": reconciled.records.get(latest.get("revision"), {}).get("history_state", latest.get("history_state")), }, "history_provenance": dict(sorted(provenance.items())), + **history, } if strength is not None: derived["sample_strength"] = strength diff --git a/tests/conformance/test_integrity.py b/tests/conformance/test_integrity.py index 7d2157d..3f94e04 100644 --- a/tests/conformance/test_integrity.py +++ b/tests/conformance/test_integrity.py @@ -160,7 +160,7 @@ def test_partial_revision_series_is_unknown_with_its_evidence_preserved(): assert result.band is None and result.evaluation_status == "UNKNOWN" and result.possible_bands is None assert result.derived["decisive_count_14d"] == 5 and result.derived["series_status"] == "PARTIAL" assert "REVISION_SERIES_PARTIAL:PAGINATION_CAPPED" in result.diagnostics - assert result.rule_id == "integrity.bands.v1+ci-unit-004" + assert result.rule_id == "integrity.bands.v1+ci-unit-004+hist-002" def test_sparse_samples_declare_their_strength_and_established_ones_do_not_carry_the_diagnostic(): @@ -277,9 +277,26 @@ def test_latest_non_decisive_revision_falls_back_to_latest_decisive_verdict(): assert any(code.startswith("LATEST_REVISION_NON_DECISIVE") for code in result.diagnostics) -def test_parent_level_provenance_is_diagnosed(): +def test_parent_level_provenance_is_diagnosed_and_a_favorable_one_is_unknown_history(): + """R52 and PV-HIST-002 HIST-06: a check suite hides its earlier outcomes, so its pass proves no history. + + The record states PASS_ONLY_OBSERVED, as a 0.1.x record would; the union + is rebuilt from its parents, and a parent-level pass is not a complete + history, so the Vital claims no band around it. + """ revs = [revision("s", "2026-09-01T00:00:00Z", [parent("suite", "VERIFY_PASS", kind="github_check_suite")], "VERIFY_PASS", "PASS_ONLY_OBSERVED", provenance="PARENT_LEVEL_ONLY")] obs = obs_set() integrity_inputs(obs, True, revs) result = integrity.evaluate(obs) - assert "HISTORY_PROVENANCE_PARENT_LEVEL_ONLY:1" in result.diagnostics + assert "HISTORY_PROVENANCE_PARENT_LEVEL_ONLY:1" in result.diagnostics and "REVISION_HISTORY_UNKNOWN:s" in result.diagnostics + assert (result.band, result.evaluation_status) == (None, "UNKNOWN") + assert result.derived["revision_history"]["records"][0]["history_state"] == "UNKNOWN_HISTORY" + + +def test_a_parent_level_failure_is_still_a_proven_failure(): + """Only favorable parent-level evidence is unknown: an observed failure is proven on any surface.""" + revs = [revision("s", "2026-09-01T00:00:00Z", [parent("suite", "VERIFY_FAIL", kind="github_check_suite")], "VERIFY_FAIL", "FAILURE_OBSERVED", provenance="PARENT_LEVEL_ONLY")] + obs = obs_set() + integrity_inputs(obs, True, revs) + result = integrity.evaluate(obs) + assert (result.band, result.evaluation_status) == ("FAILING", "AVAILABLE") and result.derived["failed_count_14d"] == 1 diff --git a/tests/test_revision_history.py b/tests/test_revision_history.py new file mode 100644 index 0000000..0f89ef4 --- /dev/null +++ b/tests/test_revision_history.py @@ -0,0 +1,268 @@ +"""Durable Integrity revision history across bundles (PV-HIST-002, target B3). + +The reconciliation cases are vectors (tests/vectors/revision-history.json). +These are the cases about the chain: which bundle a build carries history +from, what happens when that bundle cannot be read, and what the carried +history does to the identity of the bundle that consumed it. +""" + +from __future__ import annotations + +import shutil + +import pytest + +from devostasis.bundle import BundleError, verify_dir +from devostasis.config import single_project +from devostasis.history import FilesystemHistoryStore +from devostasis.observations import Observation, ObservationSet +from devostasis.revision_history import CARRIED, LINEAGE, REPLAYABLE_LINEAGE +from devostasis.runner import build_from_observations +from helpers import add, finalize, full_inputs, obs_set, parent, revision + +LOCATOR = "acme/widget" +PROJECT_ID = "42" + + +def _run(run_id, attempts, latest=None): + """An Actions parent; ``attempts`` are the (number, state) pairs the provider shows.""" + latest = latest or max(n for n, _ in attempts) + record = parent(f"github_actions:workflow_run:{run_id}", attempts[-1][1], [s for _, s in attempts]) + record["attempts_observed"] = [{"attempt": n, "state": s} for n, s in sorted(attempts)] + record["current_attempt"] = latest + record["attempts_complete"] = len(attempts) == latest + return record + + +def _record(sha, day, parents): + states = [a["state"] for p in parents for a in p["attempts_observed"]] + complete = all(p["kind"] == "github_actions_workflow_run" and p["attempts_complete"] for p in parents) + if not parents: + history = "NO_DECISIVE_OBSERVED" + elif "VERIFY_FAIL" in states: + history = "FAILURE_OBSERVED" + elif not complete: + history = "UNKNOWN_HISTORY" + else: + history = "PASS_ONLY_OBSERVED" + current = [p["current_state"] for p in parents] + verdict = "VERIFY_FAIL" if "VERIFY_FAIL" in current else ("VERIFY_PASS" if current else None) + record = revision(sha, f"2026-09-{day:02d}T10:00:00Z", parents, verdict, history) + record["history_complete"] = complete + return record + + +def _passing(days): + return [_record(f"p{day}", day, [_run(800 + day, [(1, "VERIFY_PASS")])]) for day in days] + + +def _obs(observed_at, revisions, project_id=PROJECT_ID, series_status="AVAILABLE"): + obs = full_inputs(obs_set(observed_at)) + obs.subject = dict(obs.subject, owner="acme", repo="widget", display_locator=LOCATOR, immutable_project_id=project_id) + item = obs.get("ci.revision_verdicts_14d") + value = revisions if series_status in ("AVAILABLE", "PARTIAL") else None + obs.replace(Observation.from_dict(dict(item.to_dict(), value=value, status=series_status, reason_code=None if series_status == "AVAILABLE" else "PROVIDER_ERROR"))) + return obs + + +def _build(store, observed_at, revisions, **kwargs): + project = single_project(LOCATOR, config_version="1") + bundle = build_from_observations(project, _obs(observed_at, revisions, **kwargs), store) + path = store.commit(bundle) + return bundle, path + + +def _integrity(bundle): + import json + + snapshot = json.loads(bundle.members["snapshot.json"].decode("utf-8")) + return next(v for v in snapshot["vitals"] if v["vital_id"] == "integrity") + + +def _carried(bundle): + import json + + observations = json.loads(bundle.members["observations.json"].decode("utf-8")) + return next(o for o in observations["observations"] if o["observation_id"] == CARRIED) + + +def _states(bundle): + return {r["revision"]: r["history_state"] for r in _integrity(bundle)["derived"]["revision_history"]["records"]} + + +RUN_R = 41 + + +def _chain(store): + """B1 sees r pass, B2 sees a same-revision failure, B3 sees only the pass again.""" + b1, _ = _build(store, "2026-09-06T12:00:00Z", _passing([1, 2, 3]) + [_record("r", 4, [_run(RUN_R, [(1, "VERIFY_PASS")])])]) + b2, b2_path = _build(store, "2026-09-07T12:00:00Z", _passing([1, 2, 3]) + [_record("r", 4, [_run(RUN_R, [(1, "VERIFY_PASS"), (2, "VERIFY_FAIL")])])]) + return b1, b2, b2_path + + +def test_hist_20_the_immediate_predecessor_carries_a_failure_an_older_bundle_did_not_see(tmp_path): + store = FilesystemHistoryStore(tmp_path) + b1, b2, _ = _chain(store) + assert _states(b1)["r"] == "PASS_ONLY_OBSERVED" and _states(b2)["r"] == "FAILURE_OBSERVED" + # B3: the provider retained only the passing retry, attempt 3, and cannot show 1 and 2. + b3, _ = _build(store, "2026-09-08T12:00:00Z", _passing([1, 2, 3, 5]) + [_record("r", 4, [_run(RUN_R, [(3, "VERIFY_PASS")], latest=3)])]) + assert _carried(b3)["value"]["source_bundle_id"] == b2.bundle_id, "the source is B2, never B1" + assert _states(b3)["r"] == "FAILURE_OBSERVED" + integrity = _integrity(b3) + assert integrity["derived"]["failed_count_14d"] == 1 and integrity["band"] == "FLAKY" + assert integrity["derived"]["revision_history"]["source"] == { + "status": "CARRIED", "lineage": LINEAGE, "bundle_id": b2.bundle_id, "observed_at": "2026-09-07T12:00:00Z", "basis": "SNAPSHOT", + } + + +def test_hist_20_and_hist_08_a_lost_predecessor_is_a_gap_and_nothing_older_is_carried(tmp_path): + store = FilesystemHistoryStore(tmp_path) + b1, b2, b2_path = _chain(store) + for member in b2_path.iterdir(): + member.unlink() + shutil.rmtree(store.project_dir(b2.project_key) / "latest") + b3, _ = _build(store, "2026-09-08T12:00:00Z", _passing([1, 2, 3, 5]) + [_record("r", 4, [])]) + carried = _carried(b3) + assert (carried["status"], carried["reason_code"]) == ("UNKNOWN", "HISTORY_GAP") + assert carried["evidence_ref"] == {"previous_bundle_id": b2.bundle_id} + integrity = _integrity(b3) + assert integrity["derived"]["revision_history"]["source"]["status"] == "HISTORY_GAP" + assert "REVISION_HISTORY_GAP:HISTORY_GAP" in integrity["diagnostics"] + assert "r" not in _states(b3), "B1's PASS_ONLY for r is not reached for across the gap" + assert b3.manifest["comparison_status"] == "HISTORY_GAP" + + +def test_the_durable_history_crosses_a_bundle_whose_verification_evidence_was_unusable(tmp_path): + """B2 claims no Integrity band, and still hands B1's failure on to B3.""" + store = FilesystemHistoryStore(tmp_path) + _build(store, "2026-09-06T12:00:00Z", _passing([1, 2, 3]) + [_record("r", 4, [_run(RUN_R, [(1, "VERIFY_FAIL")])])]) + b2, _ = _build(store, "2026-09-07T12:00:00Z", [], series_status="ERROR") + assert _integrity(b2)["band"] is None and _states(b2)["r"] == "FAILURE_OBSERVED" + b3, _ = _build(store, "2026-09-08T12:00:00Z", _passing([1, 2, 3, 5]) + [_record("r", 4, [])]) + assert _states(b3)["r"] == "FAILURE_OBSERVED" and _integrity(b3)["derived"]["failed_count_14d"] == 1 + + +def test_hist_12_an_unrelated_vital_rule_change_keeps_the_durable_history(tmp_path): + """A predecessor that compares INCOMPARABLE for another reason still hands its history on.""" + store = FilesystemHistoryStore(tmp_path) + _build(store, "2026-09-06T12:00:00Z", _passing([1, 2, 3]) + [_record("r", 4, [_run(RUN_R, [(1, "VERIFY_FAIL")])])]) + project = single_project(LOCATOR, config_version="1", debt={"source": "labels", "labels": ["tech-debt"], "mapping_version": "2"}) + obs = _obs("2026-09-07T12:00:00Z", _passing([1, 2, 3, 5]) + [_record("r", 4, [])]) + bundle = build_from_observations(project, obs, store) + assert bundle.manifest["comparison_status"] == "INCOMPARABLE" + assert _states(bundle)["r"] == "FAILURE_OBSERVED" + + +def _older_bundle(project, obs): + """A bundle as 0.1.x wrote it: observations without carried history, an Integrity result without revision_history.""" + from devostasis import activity as activity_mod + from devostasis import delta as delta_mod + from devostasis.bundle import build_bundle + from devostasis.vitals import build_snapshot, evaluate_all + + snapshot = build_snapshot(obs, evaluate_all(obs)) + for vital in snapshot["vitals"]: + if vital["vital_id"] == "integrity": + vital["derived"].pop("revision_history", None) + delta = delta_mod.compare(snapshot, None, delta_mod.BASELINE, None, []) + activity = activity_mod.build_activity(obs, None, None, project.activity_list_cap) if project.activity_enabled else None + return build_bundle(project, obs, snapshot, delta, activity, None, delta_mod.BASELINE, None) + + +def test_hist_13_the_first_bundle_after_an_older_version_replays_its_revision_records(tmp_path): + """A 0.1.x predecessor has no carrier in its snapshot; its revision records are replayed, never copied.""" + store = FilesystemHistoryStore(tmp_path) + project = single_project(LOCATOR, config_version="1") + older = _older_bundle(project, _obs("2026-09-06T12:00:00Z", _passing([1, 2, 3]) + [_record("r", 4, [_run(RUN_R, [(1, "VERIFY_FAIL"), (2, "VERIFY_PASS")])])])) + store.commit(older) + b2, _ = _build(store, "2026-09-07T12:00:00Z", _passing([1, 2, 3, 5]) + [_record("r", 4, [])]) + carried = _carried(b2) + assert carried["value"]["lineage"] == REPLAYABLE_LINEAGE and carried["value"]["source_bundle_id"] == older.bundle_id + assert _integrity(b2)["derived"]["revision_history"]["source"]["basis"] == "REPLAYED_SERIES" + assert _states(b2)["r"] == "FAILURE_OBSERVED" + + +def test_hist_17_different_durable_history_is_different_canonical_evidence(tmp_path): + """The same current observation over two different histories never yields one bundle.""" + current = _passing([1, 2, 3, 5]) + [_record("r", 4, [])] + ids, states = [], [] + for name, prior in (("failed", [(1, "VERIFY_FAIL")]), ("passed", [(1, "VERIFY_PASS")])): + store = FilesystemHistoryStore(tmp_path / name) + _build(store, "2026-09-06T12:00:00Z", _passing([1, 2, 3]) + [_record("r", 4, [_run(RUN_R, prior)])]) + bundle, _ = _build(store, "2026-09-07T12:00:00Z", current) + ids.append(bundle.bundle_id) + states.append(_states(bundle)["r"]) + assert states == ["FAILURE_OBSERVED", "PASS_ONLY_OBSERVED"] + assert ids[0] != ids[1], "hidden store state never changes an evaluation without a canonical trace" + + +def test_hist_18_another_project_at_the_same_locator_is_never_carried(tmp_path): + store = FilesystemHistoryStore(tmp_path) + _build(store, "2026-09-06T12:00:00Z", _passing([1, 2, 3]) + [_record("r", 4, [_run(RUN_R, [(1, "VERIFY_FAIL")])])]) + project = single_project(LOCATOR, config_version="1") + obs = _obs("2026-09-07T12:00:00Z", _passing([1, 2, 3, 5]) + [_record("r", 4, [])], project_id="7777") + bundle = build_from_observations(project, obs, store) + carried = next(o for o in __import__("json").loads(bundle.members["observations.json"])["observations"] if o["observation_id"] == CARRIED) + assert carried["status"] != "AVAILABLE", "a foreign project's history is never merged" + assert "r" not in _states(bundle) + + +def test_hist_19_complete_evidence_repairs_an_unknown_history(tmp_path): + """The nearby valid repair: once every attempt is observed, the prior unknown history is proven pass-only.""" + store = FilesystemHistoryStore(tmp_path) + b1, _ = _build(store, "2026-09-06T12:00:00Z", _passing([1, 2, 3]) + [_record("r", 4, [_run(RUN_R, [(3, "VERIFY_PASS")], latest=3)])]) + assert _states(b1)["r"] == "UNKNOWN_HISTORY" and _integrity(b1)["band"] is None + b2, _ = _build(store, "2026-09-07T12:00:00Z", _passing([1, 2, 3]) + [_record("r", 4, [_run(RUN_R, [(1, "VERIFY_PASS"), (2, "VERIFY_PASS"), (3, "VERIFY_PASS")])])]) + assert _states(b2)["r"] == "PASS_ONLY_OBSERVED" and _integrity(b2)["band"] == "CLEAN" + + +def test_a_build_refuses_observations_that_carry_history_from_another_predecessor(tmp_path): + store = FilesystemHistoryStore(tmp_path) + b1, _ = _build(store, "2026-09-06T12:00:00Z", _passing([1, 2, 3])) + import json + + replayed = ObservationSet.from_dict(json.loads(b1.members["observations.json"])) + assert replayed.get(CARRIED).reason_code == "NO_PREVIOUS_BUNDLE" + replayed.observed_at = "2026-09-07T12:00:00Z" + with pytest.raises(BundleError, match="HISTORY_SOURCE_NOT_PREDECESSOR"): + build_from_observations(single_project(LOCATOR, config_version="1"), replayed, store) + + +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])) + bundle = build_from_observations(single_project(LOCATOR, config_version="1"), obs, store) + assert CARRIED not in obs + assert _carried(bundle)["reason_code"] == "NO_PREVIOUS_BUNDLE" + + +def test_carried_history_that_is_not_its_lineages_shape_fails_closed(): + obs = finalize(obs_set("2026-09-10T12:00:00Z")) + add(obs, "ci.configured", True, "boolean") + add(obs, "ci.revision_verdicts_14d", _passing([1, 2, 3, 4]), "series") + add(obs, CARRIED, {"lineage": LINEAGE, "records": [{"revision": "r", "committed_at": "not a time", "parents": []}]}, "record") + from devostasis.vitals import integrity + + 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_every_stored_bundle_verifies_with_its_carried_history(tmp_path): + store = FilesystemHistoryStore(tmp_path) + b1, b2, b2_path = _chain(store) + assert verify_dir(b2_path) == [] + + +def test_verify_names_carried_history_that_is_not_from_the_previous_bundle(tmp_path): + """Section C: the selection of the source is auditable, so a bundle that names another source does not verify.""" + import json + + from devostasis.bundle import _check_history_source + + store = FilesystemHistoryStore(tmp_path) + _, b2, _ = _chain(store) + observations = json.loads(b2.members["observations.json"]) + assert _check_history_source(b2.members, b2.manifest, observations) == [] + problems = _check_history_source(b2.members, dict(b2.manifest, previous_bundle_id="c" * 64), observations) + assert len(problems) == 2 and all(problem.startswith("HISTORY_SOURCE_MISMATCH") for problem in problems) diff --git a/tests/vectors/integrity-totality.json b/tests/vectors/integrity-totality.json index 7993302..26e632a 100644 --- a/tests/vectors/integrity-totality.json +++ b/tests/vectors/integrity-totality.json @@ -68,7 +68,7 @@ "failed_count_14d": 0, "unresolved_verdict_rule": "PV-INTEGRITY-TOTALITY-001" }, - "rule_id": "integrity.bands.v1+ci-unit-004" + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002" } }, { @@ -191,7 +191,7 @@ "failed_count_14d": 0, "unresolved_verdict_rule": "PV-INTEGRITY-TOTALITY-001" }, - "rule_id": "integrity.bands.v1+ci-unit-004" + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002" } }, { @@ -314,7 +314,7 @@ "failed_count_14d": 1, "unresolved_verdict_rule": "PV-INTEGRITY-TOTALITY-001" }, - "rule_id": "integrity.bands.v1+ci-unit-004" + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002" } }, { @@ -491,7 +491,7 @@ "failed_count_14d": 0, "unresolved_verdict_rule": "PV-INTEGRITY-TOTALITY-001" }, - "rule_id": "integrity.bands.v1+ci-unit-004" + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002" } }, { @@ -695,7 +695,7 @@ "failed_count_14d": 1, "unresolved_verdict_rule": "PV-INTEGRITY-TOTALITY-001" }, - "rule_id": "integrity.bands.v1+ci-unit-004" + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002" } }, { @@ -876,7 +876,7 @@ "LATEST_REVISION_NON_DECISIVE", "CURRENT_VERIFICATION_UNRESOLVED" ], - "rule_id": "integrity.bands.v1+ci-unit-004" + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002" } }, { @@ -941,7 +941,7 @@ "diagnostics": [ "CI_UNINSTRUMENTED_WITH_RECENT_NONDECISIVE_HISTORY" ], - "rule_id": "integrity.bands.v1+ci-unit-004" + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002" } }, { @@ -1006,7 +1006,7 @@ "diagnostics": [ "CI_UNINSTRUMENTED_WITH_RECENT_NONDECISIVE_HISTORY" ], - "rule_id": "integrity.bands.v1+ci-unit-004" + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002" } }, { @@ -1093,7 +1093,7 @@ "diagnostics_absent": [ "CI_UNINSTRUMENTED_WITH_RECENT_NONDECISIVE_HISTORY" ], - "rule_id": "integrity.bands.v1+ci-unit-004" + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002" } }, { @@ -1268,7 +1268,7 @@ "diagnostics_absent": [ "CI_CURRENT_VERIFY_UNRESOLVED" ], - "rule_id": "integrity.bands.v1+ci-unit-004" + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002" } }, { @@ -1444,7 +1444,7 @@ "diagnostics_absent": [ "CI_CURRENT_VERIFY_UNRESOLVED" ], - "rule_id": "integrity.bands.v1+ci-unit-004" + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002" } }, { @@ -1614,7 +1614,7 @@ "evaluation_status": "DEGRADED", "band_semantics": "EXACT", "possible_bands": null, - "rule_id": "integrity.bands.v1+ci-unit-004", + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002", "derived": { "decisive_count_14d": 4, "failed_count_14d": 1 diff --git a/tests/vectors/integrity-unknown.json b/tests/vectors/integrity-unknown.json index 109536a..ec4fc41 100644 --- a/tests/vectors/integrity-unknown.json +++ b/tests/vectors/integrity-unknown.json @@ -30,7 +30,7 @@ "evaluation_status": "UNKNOWN", "band_semantics": null, "possible_bands": null, - "rule_id": "integrity.bands.v1+ci-unit-004", + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002", "derived": {"decisive_count_14d": 4, "failed_count_14d": 0}, "diagnostics": ["CURRENT_VERDICT_UNKNOWN:r5"], "diagnostics_absent": ["LATEST_REVISION_NON_DECISIVE"] @@ -88,7 +88,7 @@ "integrity": { "band": null, "evaluation_status": "UNKNOWN", - "rule_id": "integrity.bands.v1+ci-unit-004", + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002", "derived": {"decisive_count_14d": 4, "failed_count_14d": 0}, "diagnostics": ["CURRENT_VERDICT_UNKNOWN:r5"], "diagnostics_absent": ["LATEST_REVISION_NON_DECISIVE"] diff --git a/tests/vectors/revision-history.json b/tests/vectors/revision-history.json new file mode 100644 index 0000000..8c40bd5 --- /dev/null +++ b/tests/vectors/revision-history.json @@ -0,0 +1,3937 @@ +{ + "schema": "devostasis.vectors.v1", + "notes": "The reconciliation cases of PV-HIST-002 (accepted by PV-REV-HIST-002), transcribed from its permanent conformance cases and adopted in 0.2.0 under integrity.bands.v1+ci-unit-004+hist-002. Each states the carried history as the observation a build adds from its immediate predecessor (ci.revision_history_carried, lineage devostasis.ci-history.v2 unless the case is about another lineage) and the current revision records in the adapter's shape. The chain cases, HIST-08, -12, -17, -18 and -20, and the store replay of HIST-13 need a history store and are tests (tests/test_revision_history.py). HIST-14 needs an accepted migration, and none exists. observed_at is 2026-09-10T12:00:00Z, so the window starts at 2026-08-27T12:00:00Z.", + "vectors": [ + { + "case": "HIST-01", + "title": "a revision a prior bundle proved failing stays FAILURE_OBSERVED when the provider now shows the same revision passing; the current verdict is PASS", + "kind": "vital", + "source_unit": "PV-HIST-002", + "given": { + "vital": "integrity", + "observed_at": "2026-09-10T12:00:00Z", + "observations": [ + { + "observation_id": "ci.configured", + "value_type": "boolean", + "value": true + }, + { + "observation_id": "ci.revision_verdicts_14d", + "value_type": "series", + "value": [ + { + "revision": "p1", + "committed_at": "2026-09-01T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:901", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p2", + "committed_at": "2026-09-02T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:902", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p3", + "committed_at": "2026-09-03T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:903", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p4", + "committed_at": "2026-09-04T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:904", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_checks:check_suite:41", + "kind": "github_check_suite", + "name": "ci-app", + "event": null, + "current_attempt": null, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": null, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": false, + "history_state": "UNKNOWN_HISTORY", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "UNKNOWN_HISTORY", + "historical_contribution": null, + "history_provenance": "PARENT_LEVEL_ONLY", + "history_complete": false + } + ] + }, + { + "observation_id": "ci.revision_history_carried", + "value_type": "record", + "value": { + "lineage": "devostasis.ci-history.v2", + "records": [ + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_checks:check_suite:41", + "kind": "github_check_suite", + "latest_attempt": null, + "attempts": [ + { + "attempt": null, + "state": "VERIFY_FAIL" + } + ] + } + ] + } + ], + "source_bundle_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "source_observed_at": "2026-09-09T12:00:00Z", + "basis": "SNAPSHOT" + } + } + ] + }, + "expect": { + "band": "FLAKY", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002", + "derived": { + "decisive_count_14d": 5, + "failed_count_14d": 1, + "current_revision": { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "current_verdict": "VERIFY_PASS", + "history_state": "FAILURE_OBSERVED" + } + }, + "diagnostics": [ + "HISTORY_PROVENANCE_PARENT_LEVEL_ONLY:1" + ] + }, + "notes": "A re-requested check suite keeps its id and shows only its latest outcome; the failure the previous bundle observed is what keeps the revision failing (R54's rule across bundles)." + }, + { + "case": "HIST-02", + "title": "a revision the prior bundle recorded PASS_ONLY_OBSERVED becomes FAILURE_OBSERVED when a failure of it is newly observed, and stays there", + "kind": "vital", + "source_unit": "PV-HIST-002", + "given": { + "vital": "integrity", + "observed_at": "2026-09-10T12:00:00Z", + "observations": [ + { + "observation_id": "ci.configured", + "value_type": "boolean", + "value": true + }, + { + "observation_id": "ci.revision_verdicts_14d", + "value_type": "series", + "value": [ + { + "revision": "r", + "committed_at": "2026-09-01T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:42", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_FAIL", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_FAIL" + } + ], + "attempts_complete": true, + "history_state": "FAILURE_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_FAIL", + "history_state": "FAILURE_OBSERVED", + "historical_contribution": "VERIFY_FAIL", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p2", + "committed_at": "2026-09-02T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:902", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p3", + "committed_at": "2026-09-03T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:903", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p4", + "committed_at": "2026-09-04T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:904", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p5", + "committed_at": "2026-09-05T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:905", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + } + ] + }, + { + "observation_id": "ci.revision_history_carried", + "value_type": "record", + "value": { + "lineage": "devostasis.ci-history.v2", + "records": [ + { + "revision": "r", + "committed_at": "2026-09-01T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:41", + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ] + } + ] + } + ], + "source_bundle_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "source_observed_at": "2026-09-09T12:00:00Z", + "basis": "SNAPSHOT" + } + } + ] + }, + "expect": { + "band": "FLAKY", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002", + "derived": { + "decisive_count_14d": 5, + "failed_count_14d": 1 + } + }, + "notes": "The run the previous bundle saw pass (41) is no longer listed; the new run 42 failed on the same revision." + }, + { + "case": "HIST-03", + "title": "a prior failure plus any number of same-revision retries that passed is one failed contribution, whatever the number or order of the retries", + "kind": "vital", + "source_unit": "PV-HIST-002", + "given": { + "vital": "integrity", + "observed_at": "2026-09-10T12:00:00Z", + "variants": [ + { + "title": "one retry", + "observations": [ + { + "observation_id": "ci.configured", + "value_type": "boolean", + "value": true + }, + { + "observation_id": "ci.revision_verdicts_14d", + "value_type": "series", + "value": [ + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:41", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 2, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 2, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": false, + "history_state": "UNKNOWN_HISTORY", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "UNKNOWN_HISTORY", + "historical_contribution": null, + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": false + } + ] + }, + { + "observation_id": "ci.revision_history_carried", + "value_type": "record", + "value": { + "lineage": "devostasis.ci-history.v2", + "records": [ + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:41", + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_FAIL" + } + ] + } + ] + } + ], + "source_bundle_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "source_observed_at": "2026-09-09T12:00:00Z", + "basis": "SNAPSHOT" + } + } + ] + }, + { + "title": "five retries", + "observations": [ + { + "observation_id": "ci.configured", + "value_type": "boolean", + "value": true + }, + { + "observation_id": "ci.revision_verdicts_14d", + "value_type": "series", + "value": [ + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:41", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 6, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 2, + "state": "VERIFY_PASS" + }, + { + "attempt": 3, + "state": "VERIFY_PASS" + }, + { + "attempt": 4, + "state": "VERIFY_PASS" + }, + { + "attempt": 5, + "state": "VERIFY_PASS" + }, + { + "attempt": 6, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": false, + "history_state": "UNKNOWN_HISTORY", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "UNKNOWN_HISTORY", + "historical_contribution": null, + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": false + } + ] + }, + { + "observation_id": "ci.revision_history_carried", + "value_type": "record", + "value": { + "lineage": "devostasis.ci-history.v2", + "records": [ + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:41", + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_FAIL" + } + ] + } + ] + } + ], + "source_bundle_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "source_observed_at": "2026-09-09T12:00:00Z", + "basis": "SNAPSHOT" + } + } + ] + }, + { + "title": "five retries listed newest first", + "observations": [ + { + "observation_id": "ci.configured", + "value_type": "boolean", + "value": true + }, + { + "observation_id": "ci.revision_verdicts_14d", + "value_type": "series", + "value": [ + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:41", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 6, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 6, + "state": "VERIFY_PASS" + }, + { + "attempt": 5, + "state": "VERIFY_PASS" + }, + { + "attempt": 4, + "state": "VERIFY_PASS" + }, + { + "attempt": 3, + "state": "VERIFY_PASS" + }, + { + "attempt": 2, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": false, + "history_state": "UNKNOWN_HISTORY", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "UNKNOWN_HISTORY", + "historical_contribution": null, + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": false + } + ] + }, + { + "observation_id": "ci.revision_history_carried", + "value_type": "record", + "value": { + "lineage": "devostasis.ci-history.v2", + "records": [ + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:41", + "kind": "github_actions_workflow_run", + "latest_attempt": 6, + "attempts": [ + { + "attempt": 6, + "state": "VERIFY_PASS" + }, + { + "attempt": 5, + "state": "VERIFY_PASS" + }, + { + "attempt": 4, + "state": "VERIFY_PASS" + }, + { + "attempt": 3, + "state": "VERIFY_PASS" + }, + { + "attempt": 2, + "state": "VERIFY_PASS" + }, + { + "attempt": 1, + "state": "VERIFY_FAIL" + } + ] + } + ] + } + ], + "source_bundle_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "source_observed_at": "2026-09-09T12:00:00Z", + "basis": "SNAPSHOT" + } + } + ] + } + ] + }, + "expect": { + "band": "SPARSE_MIXED", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002", + "derived": { + "decisive_count_14d": 1, + "failed_count_14d": 1, + "revisions_with_history": 1 + } + } + }, + { + "case": "HIST-04", + "title": "a failed revision and a newer passing revision are two samples while both are in the window", + "kind": "vital", + "source_unit": "PV-HIST-002", + "given": { + "vital": "integrity", + "observed_at": "2026-09-10T12:00:00Z", + "observations": [ + { + "observation_id": "ci.configured", + "value_type": "boolean", + "value": true + }, + { + "observation_id": "ci.revision_verdicts_14d", + "value_type": "series", + "value": [ + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [], + "current_verdict": null, + "history_state": "NO_DECISIVE_OBSERVED", + "historical_contribution": null, + "history_provenance": null, + "history_complete": true + }, + { + "revision": "r2", + "committed_at": "2026-09-07T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:43", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + } + ] + }, + { + "observation_id": "ci.revision_history_carried", + "value_type": "record", + "value": { + "lineage": "devostasis.ci-history.v2", + "records": [ + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:41", + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_FAIL" + } + ] + } + ] + } + ], + "source_bundle_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "source_observed_at": "2026-09-09T12:00:00Z", + "basis": "SNAPSHOT" + } + } + ] + }, + "expect": { + "band": "SPARSE_MIXED", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002", + "derived": { + "decisive_count_14d": 2, + "failed_count_14d": 1, + "revisions_with_history": 2, + "revisions_with_verification": 1 + } + } + }, + { + "case": "HIST-05", + "title": "a failure the provider no longer exposes stays historical evidence while its revision is in the window", + "kind": "vital", + "source_unit": "PV-HIST-002", + "given": { + "vital": "integrity", + "observed_at": "2026-09-10T12:00:00Z", + "observations": [ + { + "observation_id": "ci.configured", + "value_type": "boolean", + "value": true + }, + { + "observation_id": "ci.revision_verdicts_14d", + "value_type": "series", + "value": [ + { + "revision": "p1", + "committed_at": "2026-09-01T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:901", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p2", + "committed_at": "2026-09-02T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:902", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p3", + "committed_at": "2026-09-03T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:903", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p4", + "committed_at": "2026-09-04T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:904", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [], + "current_verdict": null, + "history_state": "NO_DECISIVE_OBSERVED", + "historical_contribution": null, + "history_provenance": null, + "history_complete": true + } + ] + }, + { + "observation_id": "ci.revision_history_carried", + "value_type": "record", + "value": { + "lineage": "devostasis.ci-history.v2", + "records": [ + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:41", + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_FAIL" + } + ] + } + ] + } + ], + "source_bundle_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "source_observed_at": "2026-09-09T12:00:00Z", + "basis": "SNAPSHOT" + } + } + ] + }, + "expect": { + "band": "FLAKY", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002", + "derived": { + "decisive_count_14d": 5, + "failed_count_14d": 1, + "revisions_with_history": 5, + "revisions_with_verification": 4 + } + } + }, + { + "case": "HIST-06", + "title": "without durable history a parent-level pass cannot prove the earlier attempts: UNKNOWN_HISTORY, no favorable PASS_ONLY reconstruction", + "kind": "vital", + "source_unit": "PV-HIST-002", + "given": { + "vital": "integrity", + "observed_at": "2026-09-10T12:00:00Z", + "variants": [ + { + "title": "a first bundle", + "observations": [ + { + "observation_id": "ci.configured", + "value_type": "boolean", + "value": true + }, + { + "observation_id": "ci.revision_verdicts_14d", + "value_type": "series", + "value": [ + { + "revision": "p1", + "committed_at": "2026-09-01T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:901", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p2", + "committed_at": "2026-09-02T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:902", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p3", + "committed_at": "2026-09-03T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:903", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p4", + "committed_at": "2026-09-04T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:904", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_checks:check_suite:41", + "kind": "github_check_suite", + "name": "ci-app", + "event": null, + "current_attempt": null, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": null, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": false, + "history_state": "UNKNOWN_HISTORY", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "UNKNOWN_HISTORY", + "historical_contribution": null, + "history_provenance": "PARENT_LEVEL_ONLY", + "history_complete": false + } + ] + }, + { + "observation_id": "ci.revision_history_carried", + "value_type": "record", + "status": "UNAVAILABLE", + "reason_code": "NO_PREVIOUS_BUNDLE" + } + ] + }, + { + "title": "no carried history stated", + "observations": [ + { + "observation_id": "ci.configured", + "value_type": "boolean", + "value": true + }, + { + "observation_id": "ci.revision_verdicts_14d", + "value_type": "series", + "value": [ + { + "revision": "p1", + "committed_at": "2026-09-01T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:901", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p2", + "committed_at": "2026-09-02T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:902", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p3", + "committed_at": "2026-09-03T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:903", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p4", + "committed_at": "2026-09-04T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:904", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_checks:check_suite:41", + "kind": "github_check_suite", + "name": "ci-app", + "event": null, + "current_attempt": null, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": null, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": false, + "history_state": "UNKNOWN_HISTORY", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "UNKNOWN_HISTORY", + "historical_contribution": null, + "history_provenance": "PARENT_LEVEL_ONLY", + "history_complete": false + } + ] + } + ] + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002", + "diagnostics": [ + "REVISION_HISTORY_UNKNOWN:r", + "HISTORY_PROVENANCE_PARENT_LEVEL_ONLY:1" + ], + "derived": { + "unknown_history_count_14d": 1, + "decisive_count_14d": 4 + } + } + }, + { + "case": "HIST-07", + "title": "complete attempt-level current evidence with no unresolved prior gap proves PASS_ONLY_OBSERVED", + "kind": "vital", + "source_unit": "PV-HIST-002", + "given": { + "vital": "integrity", + "observed_at": "2026-09-10T12:00:00Z", + "variants": [ + { + "title": "a first bundle", + "observations": [ + { + "observation_id": "ci.configured", + "value_type": "boolean", + "value": true + }, + { + "observation_id": "ci.revision_verdicts_14d", + "value_type": "series", + "value": [ + { + "revision": "p1", + "committed_at": "2026-09-01T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:901", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p2", + "committed_at": "2026-09-02T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:902", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p3", + "committed_at": "2026-09-03T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:903", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p4", + "committed_at": "2026-09-04T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:904", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:41", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 2, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + }, + { + "attempt": 2, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + } + ] + }, + { + "observation_id": "ci.revision_history_carried", + "value_type": "record", + "status": "UNAVAILABLE", + "reason_code": "NO_PREVIOUS_BUNDLE" + } + ] + }, + { + "title": "carried history without this revision", + "observations": [ + { + "observation_id": "ci.configured", + "value_type": "boolean", + "value": true + }, + { + "observation_id": "ci.revision_verdicts_14d", + "value_type": "series", + "value": [ + { + "revision": "p1", + "committed_at": "2026-09-01T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:901", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p2", + "committed_at": "2026-09-02T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:902", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p3", + "committed_at": "2026-09-03T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:903", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p4", + "committed_at": "2026-09-04T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:904", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:41", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 2, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + }, + { + "attempt": 2, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + } + ] + }, + { + "observation_id": "ci.revision_history_carried", + "value_type": "record", + "value": { + "lineage": "devostasis.ci-history.v2", + "records": [ + { + "revision": "p1", + "committed_at": "2026-09-01T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:901", + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ] + } + ] + } + ], + "source_bundle_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "source_observed_at": "2026-09-09T12:00:00Z", + "basis": "SNAPSHOT" + } + } + ] + } + ] + }, + "expect": { + "band": "CLEAN", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002", + "derived": { + "decisive_count_14d": 5, + "failed_count_14d": 0, + "unknown_history_count_14d": 0 + } + } + }, + { + "case": "HIST-09", + "title": "the same failure in durable history and in the current enumeration is one failed contribution, never two", + "kind": "vital", + "source_unit": "PV-HIST-002", + "given": { + "vital": "integrity", + "observed_at": "2026-09-10T12:00:00Z", + "observations": [ + { + "observation_id": "ci.configured", + "value_type": "boolean", + "value": true + }, + { + "observation_id": "ci.revision_verdicts_14d", + "value_type": "series", + "value": [ + { + "revision": "r", + "committed_at": "2026-09-01T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:41", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_FAIL", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_FAIL" + } + ], + "attempts_complete": true, + "history_state": "FAILURE_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_FAIL", + "history_state": "FAILURE_OBSERVED", + "historical_contribution": "VERIFY_FAIL", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p2", + "committed_at": "2026-09-02T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:902", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p3", + "committed_at": "2026-09-03T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:903", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p4", + "committed_at": "2026-09-04T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:904", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p5", + "committed_at": "2026-09-05T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:905", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + } + ] + }, + { + "observation_id": "ci.revision_history_carried", + "value_type": "record", + "value": { + "lineage": "devostasis.ci-history.v2", + "records": [ + { + "revision": "r", + "committed_at": "2026-09-01T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:41", + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_FAIL" + } + ] + } + ] + } + ], + "source_bundle_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "source_observed_at": "2026-09-09T12:00:00Z", + "basis": "SNAPSHOT" + } + } + ] + }, + "expect": { + "band": "FLAKY", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002", + "derived": { + "decisive_count_14d": 5, + "failed_count_14d": 1 + } + } + }, + { + "case": "HIST-10", + "title": "a revision whose durable failure has aged beyond the 14-day window no longer contributes", + "kind": "vital", + "source_unit": "PV-HIST-002", + "given": { + "vital": "integrity", + "observed_at": "2026-09-10T12:00:00Z", + "observations": [ + { + "observation_id": "ci.configured", + "value_type": "boolean", + "value": true + }, + { + "observation_id": "ci.revision_verdicts_14d", + "value_type": "series", + "value": [ + { + "revision": "p1", + "committed_at": "2026-09-01T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:901", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p2", + "committed_at": "2026-09-02T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:902", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p3", + "committed_at": "2026-09-03T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:903", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p4", + "committed_at": "2026-09-04T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:904", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + } + ] + }, + { + "observation_id": "ci.revision_history_carried", + "value_type": "record", + "value": { + "lineage": "devostasis.ci-history.v2", + "records": [ + { + "revision": "old", + "committed_at": "2026-08-20T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:40", + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_FAIL" + } + ] + } + ] + } + ], + "source_bundle_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "source_observed_at": "2026-09-09T12:00:00Z", + "basis": "SNAPSHOT" + } + } + ] + }, + "expect": { + "band": "CLEAN", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002", + "derived": { + "decisive_count_14d": 4, + "failed_count_14d": 0, + "revisions_with_history": 4 + } + }, + "notes": "The aged revision is also absent from the durable output of this bundle; the bundle that proved it keeps it." + }, + { + "case": "HIST-11", + "title": "equivalent failure histories on the Actions and the check-suite surfaces, in any enumeration order, give the identical record and result", + "kind": "vital", + "source_unit": "PV-HIST-002", + "given": { + "vital": "integrity", + "observed_at": "2026-09-10T12:00:00Z", + "variants": [ + { + "title": "Actions, attempts oldest first", + "observations": [ + { + "observation_id": "ci.configured", + "value_type": "boolean", + "value": true + }, + { + "observation_id": "ci.revision_verdicts_14d", + "value_type": "series", + "value": [ + { + "revision": "p1", + "committed_at": "2026-09-01T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:901", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p2", + "committed_at": "2026-09-02T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:902", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p3", + "committed_at": "2026-09-03T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:903", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p4", + "committed_at": "2026-09-04T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:904", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:41", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 2, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 2, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": false, + "history_state": "UNKNOWN_HISTORY", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "UNKNOWN_HISTORY", + "historical_contribution": null, + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": false + } + ] + }, + { + "observation_id": "ci.revision_history_carried", + "value_type": "record", + "value": { + "lineage": "devostasis.ci-history.v2", + "records": [ + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:41", + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_FAIL" + } + ] + } + ] + } + ], + "source_bundle_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "source_observed_at": "2026-09-09T12:00:00Z", + "basis": "SNAPSHOT" + } + } + ] + }, + { + "title": "Actions, records and attempts reversed", + "observations": [ + { + "observation_id": "ci.configured", + "value_type": "boolean", + "value": true + }, + { + "observation_id": "ci.revision_verdicts_14d", + "value_type": "series", + "value": [ + { + "revision": "p4", + "committed_at": "2026-09-04T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:904", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p3", + "committed_at": "2026-09-03T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:903", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p2", + "committed_at": "2026-09-02T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:902", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p1", + "committed_at": "2026-09-01T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:901", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:41", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 2, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 2, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": false, + "history_state": "UNKNOWN_HISTORY", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "UNKNOWN_HISTORY", + "historical_contribution": null, + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": false + } + ] + }, + { + "observation_id": "ci.revision_history_carried", + "value_type": "record", + "value": { + "lineage": "devostasis.ci-history.v2", + "records": [ + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:41", + "kind": "github_actions_workflow_run", + "latest_attempt": 2, + "attempts": [ + { + "attempt": 2, + "state": "VERIFY_PASS" + }, + { + "attempt": 1, + "state": "VERIFY_FAIL" + } + ] + } + ] + } + ], + "source_bundle_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "source_observed_at": "2026-09-09T12:00:00Z", + "basis": "SNAPSHOT" + } + } + ] + }, + { + "title": "check suite", + "observations": [ + { + "observation_id": "ci.configured", + "value_type": "boolean", + "value": true + }, + { + "observation_id": "ci.revision_verdicts_14d", + "value_type": "series", + "value": [ + { + "revision": "p1", + "committed_at": "2026-09-01T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:901", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p2", + "committed_at": "2026-09-02T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:902", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p3", + "committed_at": "2026-09-03T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:903", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p4", + "committed_at": "2026-09-04T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:904", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_checks:check_suite:41", + "kind": "github_check_suite", + "name": "ci-app", + "event": null, + "current_attempt": null, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": null, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": false, + "history_state": "UNKNOWN_HISTORY", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "UNKNOWN_HISTORY", + "historical_contribution": null, + "history_provenance": "PARENT_LEVEL_ONLY", + "history_complete": false + } + ] + }, + { + "observation_id": "ci.revision_history_carried", + "value_type": "record", + "value": { + "lineage": "devostasis.ci-history.v2", + "records": [ + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_checks:check_suite:41", + "kind": "github_check_suite", + "latest_attempt": null, + "attempts": [ + { + "attempt": null, + "state": "VERIFY_FAIL" + } + ] + } + ] + } + ], + "source_bundle_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "source_observed_at": "2026-09-09T12:00:00Z", + "basis": "SNAPSHOT" + } + } + ] + } + ] + }, + "expect": { + "band": "FLAKY", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002", + "derived": { + "decisive_count_14d": 5, + "failed_count_14d": 1 + } + }, + "notes": "GitLab-equivalent evidence (the third surface the case names) needs a GitLab adapter this version does not have." + }, + { + "case": "HIST-13", + "title": "carried history of the older lineage is replayed from the attempts it kept, never copied from the state it derived: its failure stays, its parent-level pass is unknown", + "kind": "vital", + "source_unit": "PV-HIST-002", + "given": { + "vital": "integrity", + "observed_at": "2026-09-10T12:00:00Z", + "observations": [ + { + "observation_id": "ci.configured", + "value_type": "boolean", + "value": true + }, + { + "observation_id": "ci.revision_verdicts_14d", + "value_type": "series", + "value": [ + { + "revision": "r1", + "committed_at": "2026-09-05T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:51", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 2, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 2, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": false, + "history_state": "UNKNOWN_HISTORY", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "UNKNOWN_HISTORY", + "historical_contribution": null, + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": false + }, + { + "revision": "r2", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [], + "current_verdict": null, + "history_state": "NO_DECISIVE_OBSERVED", + "historical_contribution": null, + "history_provenance": null, + "history_complete": true + } + ] + }, + { + "observation_id": "ci.revision_history_carried", + "value_type": "record", + "value": { + "lineage": "devostasis.ci-history.v1", + "records": [ + { + "revision": "r1", + "committed_at": "2026-09-05T10:00:00Z", + "history_state": "FAILURE_OBSERVED", + "historical_contribution": "VERIFY_FAIL", + "parents": [ + { + "parent_id": "github_actions:workflow_run:51", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 2, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_FAIL" + }, + { + "attempt": 2, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "FAILURE_OBSERVED", + "url": null + } + ] + }, + { + "revision": "r2", + "committed_at": "2026-09-06T10:00:00Z", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "parents": [ + { + "parent_id": "github_checks:check_suite:52", + "kind": "github_check_suite", + "name": "ci-app", + "event": null, + "current_attempt": null, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": null, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": false, + "history_state": "UNKNOWN_HISTORY", + "url": null + } + ] + } + ], + "source_bundle_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "source_observed_at": "2026-09-09T12:00:00Z", + "basis": "SNAPSHOT" + } + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002", + "diagnostics": [ + "REVISION_HISTORY_UNKNOWN:r2" + ], + "derived": { + "failed_count_14d": 1, + "unknown_history_count_14d": 1 + } + }, + "notes": "devostasis.ci-history.v1 is the revision record 0.1.x bundles carry in ci.revision_verdicts_14d: its attempts are canonical, so the union is rebuilt from them under the current lineage. The 0.1.x record called r2 PASS_ONLY_OBSERVED from a check suite; R52 does not, and replay applies it." + }, + { + "case": "HIST-15", + "title": "carried history of a lineage with neither replay nor an accepted migration leaves the revisions it names unknown, until complete current evidence repairs them", + "kind": "vital", + "source_unit": "PV-HIST-002", + "given": { + "vital": "integrity", + "observed_at": "2026-09-10T12:00:00Z", + "variants": [ + { + "title": "no current verification", + "observations": [ + { + "observation_id": "ci.configured", + "value_type": "boolean", + "value": true + }, + { + "observation_id": "ci.revision_verdicts_14d", + "value_type": "series", + "value": [ + { + "revision": "p1", + "committed_at": "2026-09-01T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:901", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p2", + "committed_at": "2026-09-02T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:902", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p3", + "committed_at": "2026-09-03T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:903", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p4", + "committed_at": "2026-09-04T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:904", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [], + "current_verdict": null, + "history_state": "NO_DECISIVE_OBSERVED", + "historical_contribution": null, + "history_provenance": null, + "history_complete": true + } + ] + }, + { + "observation_id": "ci.revision_history_carried", + "value_type": "record", + "value": { + "lineage": "devostasis.ci-history.v9", + "records": [ + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [] + } + ], + "source_bundle_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "source_observed_at": "2026-09-09T12:00:00Z", + "basis": "SNAPSHOT" + } + } + ] + }, + { + "title": "a parent-level pass now", + "observations": [ + { + "observation_id": "ci.configured", + "value_type": "boolean", + "value": true + }, + { + "observation_id": "ci.revision_verdicts_14d", + "value_type": "series", + "value": [ + { + "revision": "p1", + "committed_at": "2026-09-01T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:901", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p2", + "committed_at": "2026-09-02T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:902", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p3", + "committed_at": "2026-09-03T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:903", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p4", + "committed_at": "2026-09-04T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:904", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_checks:check_suite:41", + "kind": "github_check_suite", + "name": "ci-app", + "event": null, + "current_attempt": null, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": null, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": false, + "history_state": "UNKNOWN_HISTORY", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "UNKNOWN_HISTORY", + "historical_contribution": null, + "history_provenance": "PARENT_LEVEL_ONLY", + "history_complete": false + } + ] + }, + { + "observation_id": "ci.revision_history_carried", + "value_type": "record", + "value": { + "lineage": "devostasis.ci-history.v9", + "records": [ + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [] + } + ], + "source_bundle_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "source_observed_at": "2026-09-09T12:00:00Z", + "basis": "SNAPSHOT" + } + } + ] + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002", + "diagnostics": [ + "REVISION_HISTORY_UNKNOWN:r" + ], + "derived": { + "unknown_history_count_14d": 1 + } + } + }, + { + "case": "HIST-16", + "title": "a prior bundle's current verdict is never inherited: the revision the provider no longer shows keeps its history and speaks for nothing current", + "kind": "vital", + "source_unit": "PV-HIST-002", + "given": { + "vital": "integrity", + "observed_at": "2026-09-10T12:00:00Z", + "observations": [ + { + "observation_id": "ci.configured", + "value_type": "boolean", + "value": true + }, + { + "observation_id": "ci.revision_verdicts_14d", + "value_type": "series", + "value": [ + { + "revision": "p1", + "committed_at": "2026-09-01T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:901", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p2", + "committed_at": "2026-09-02T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:902", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p3", + "committed_at": "2026-09-03T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:903", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p4", + "committed_at": "2026-09-04T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:904", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "r", + "committed_at": "2026-09-09T10:00:00Z", + "parents": [], + "current_verdict": null, + "history_state": "NO_DECISIVE_OBSERVED", + "historical_contribution": null, + "history_provenance": null, + "history_complete": true + } + ] + }, + { + "observation_id": "ci.revision_history_carried", + "value_type": "record", + "value": { + "lineage": "devostasis.ci-history.v2", + "records": [ + { + "revision": "r", + "committed_at": "2026-09-09T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:41", + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_FAIL" + } + ] + } + ] + } + ], + "source_bundle_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "source_observed_at": "2026-09-09T12:00:00Z", + "basis": "SNAPSHOT" + } + } + ] + }, + "expect": { + "band": "FLAKY", + "evaluation_status": "AVAILABLE", + "band_semantics": "EXACT", + "possible_bands": null, + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002", + "derived": { + "decisive_count_14d": 5, + "failed_count_14d": 1, + "current_revision": { + "revision": "p4", + "committed_at": "2026-09-04T10:00:00Z", + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED" + } + } + }, + "notes": "r is the newest revision and failed in the previous bundle; with no current verification it contributes its failure and is not the current reference." + }, + { + "case": "HIST-19", + "title": "a prior UNKNOWN_HISTORY does not heal because the provider now shows a pass that is complete only for what is visible", + "kind": "vital", + "source_unit": "PV-HIST-002", + "given": { + "vital": "integrity", + "observed_at": "2026-09-10T12:00:00Z", + "variants": [ + { + "title": "attempts 1 and 2 still unseen", + "observations": [ + { + "observation_id": "ci.configured", + "value_type": "boolean", + "value": true + }, + { + "observation_id": "ci.revision_verdicts_14d", + "value_type": "series", + "value": [ + { + "revision": "p1", + "committed_at": "2026-09-01T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:901", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p2", + "committed_at": "2026-09-02T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:902", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p3", + "committed_at": "2026-09-03T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:903", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p4", + "committed_at": "2026-09-04T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:904", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:41", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 3, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 3, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": false, + "history_state": "UNKNOWN_HISTORY", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "UNKNOWN_HISTORY", + "historical_contribution": null, + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": false + } + ] + }, + { + "observation_id": "ci.revision_history_carried", + "value_type": "record", + "value": { + "lineage": "devostasis.ci-history.v2", + "records": [ + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:41", + "kind": "github_actions_workflow_run", + "latest_attempt": 3, + "attempts": [ + { + "attempt": 3, + "state": "VERIFY_PASS" + } + ] + } + ] + } + ], + "source_bundle_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "source_observed_at": "2026-09-09T12:00:00Z", + "basis": "SNAPSHOT" + } + } + ] + }, + { + "title": "a check suite that passes now", + "observations": [ + { + "observation_id": "ci.configured", + "value_type": "boolean", + "value": true + }, + { + "observation_id": "ci.revision_verdicts_14d", + "value_type": "series", + "value": [ + { + "revision": "p1", + "committed_at": "2026-09-01T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:901", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p2", + "committed_at": "2026-09-02T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:902", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p3", + "committed_at": "2026-09-03T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:903", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p4", + "committed_at": "2026-09-04T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:904", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_checks:check_suite:41", + "kind": "github_check_suite", + "name": "ci-app", + "event": null, + "current_attempt": null, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": null, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": false, + "history_state": "UNKNOWN_HISTORY", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "UNKNOWN_HISTORY", + "historical_contribution": null, + "history_provenance": "PARENT_LEVEL_ONLY", + "history_complete": false + } + ] + }, + { + "observation_id": "ci.revision_history_carried", + "value_type": "record", + "value": { + "lineage": "devostasis.ci-history.v2", + "records": [ + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [ + { + "parent_id": "github_checks:check_suite:41", + "kind": "github_check_suite", + "latest_attempt": null, + "attempts": [ + { + "attempt": null, + "state": "VERIFY_UNRESOLVED" + } + ] + } + ] + } + ], + "source_bundle_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "source_observed_at": "2026-09-09T12:00:00Z", + "basis": "SNAPSHOT" + } + } + ] + }, + { + "title": "an unresolved gap marker", + "observations": [ + { + "observation_id": "ci.configured", + "value_type": "boolean", + "value": true + }, + { + "observation_id": "ci.revision_verdicts_14d", + "value_type": "series", + "value": [ + { + "revision": "p1", + "committed_at": "2026-09-01T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:901", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p2", + "committed_at": "2026-09-02T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:902", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p3", + "committed_at": "2026-09-03T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:903", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "p4", + "committed_at": "2026-09-04T10:00:00Z", + "parents": [ + { + "parent_id": "github_actions:workflow_run:904", + "kind": "github_actions_workflow_run", + "name": "ci", + "event": "push", + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "attempts_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "url": null + } + ], + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "historical_contribution": "VERIFY_PASS", + "history_provenance": "ATTEMPT_LEVEL", + "history_complete": true + }, + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [], + "current_verdict": null, + "history_state": "NO_DECISIVE_OBSERVED", + "historical_contribution": null, + "history_provenance": null, + "history_complete": true + } + ] + }, + { + "observation_id": "ci.revision_history_carried", + "value_type": "record", + "value": { + "lineage": "devostasis.ci-history.v2", + "records": [ + { + "revision": "r", + "committed_at": "2026-09-06T10:00:00Z", + "parents": [], + "unresolved": [ + "LINEAGE_INCOMPATIBLE:devostasis.ci-history.v9" + ] + } + ], + "source_bundle_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "source_observed_at": "2026-09-09T12:00:00Z", + "basis": "SNAPSHOT" + } + } + ] + } + ] + }, + "expect": { + "band": null, + "evaluation_status": "UNKNOWN", + "band_semantics": null, + "possible_bands": null, + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002", + "diagnostics": [ + "REVISION_HISTORY_UNKNOWN:r" + ], + "derived": { + "unknown_history_count_14d": 1, + "decisive_count_14d": 4 + } + }, + "notes": "The nearby repair, complete canonical evidence of every attempt making the history PASS_ONLY_OBSERVED, is test_hist_19_complete_evidence_repairs_an_unknown_history." + } + ] +} From e54c232875759597e3dcad9b48f04a90c34634b4 Mon Sep 17 00:00:00 2001 From: Dreven Date: Thu, 1 Oct 2026 00:59:54 +0300 Subject: [PATCH 06/33] Hold every cell of the nine former T9 gap families to its accepted obligation PV-REV-TEST-004 accepted the final T9 reconciliation (PV-TEST-004, generator PV-T9-GEN-003) with zero CONTRACT_GAP terminals: each of the nine gap identifiers PV-T9-GEN-002 exposed maps to one deterministic obligation, and higher-precedence fail-closed conditions stay higher. With the Direction, Horizon and Debt repairs of this release every obligation T9 consumes is implemented, so T9 can be adopted. The implementation side of it is a finite generator: the acquisition tokens (CF, POS_ABSENT, PARTIAL, UNAVAILABLE, FORBIDDEN, UNKNOWN, ERROR, STALE) crossed with the regions each family names, about 1,270 cells, each evaluated and held to the accepted table: Horizon PH0..PH3, Clutter's confirmed floors, Direction's completion set and per-reference resolution, Integrity's unresolved, partial and unconfigured-recent regions, Debt's PD0 and PD1, and unknown history above every conservative branch. The oracles restate the accepted table rather than calling the evaluators. The T9v2 cell-key serialization remains the research process's to deliver as vectors. Co-Authored-By: Claude Opus 5.5 --- docs/spec/PROVENANCE.md | 1 + docs/spec/conformance.md | 30 +- tests/conformance/test_t9_reconciliation.py | 357 ++++++++++++++++++++ 3 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 tests/conformance/test_t9_reconciliation.py diff --git a/docs/spec/PROVENANCE.md b/docs/spec/PROVENANCE.md index b02d1e4..e3f8093 100644 --- a/docs/spec/PROVENANCE.md +++ b/docs/spec/PROVENANCE.md @@ -23,6 +23,7 @@ normative here. The process is domain-neutral and documented in the | `debt.bands.v2` | PV-DEBT-PARTIAL-001 (accepted by PV-REV-DEBT-PARTIAL-001, DEBT-PARTIAL-01..16); the Debt part of issue #39 | accepted; adopted in 0.2.0: a confirmed open item under partial acquisition is `DEGRADED / PRESENT / EXACT`, none confirmed is `UNKNOWN`; a PARTIAL count is read only with its subset proof. The Pulse part of #39 waits for the research answer it asks for | | target marker grammar | PV-AUDIT-TARGET-MARKER-SYNTAX-001 (REPAIR REQUIRED, regressions LINK-MARKER-01..08 known only by category) | repaired in 0.2.0 in the adapter; no Vital rule changes for it, the Direction rule version it lands under moves for the repairs above | | `integrity.bands.v1+ci-unit-004+hist-002`, `devostasis.ci-history.v2` | PV-HIST-002 (accepted by PV-REV-HIST-002, HIST-01..20; supersedes the PV-HIST-001 candidate judged CHANGES_REQUIRED by PV-REV-HIST-001) | accepted; adopted in 0.2.0 as target B3: the per-revision union of observed attempts is carried from the immediate predecessor (`ci.revision_history_carried` in, `derived.revision_history` out), R52's completeness rule makes favorable incomplete evidence `UNKNOWN_HISTORY`, and 0.1.x revision records are replayed as lineage `devostasis.ci-history.v1`. HIST-14 has no accepted migration to execute; the GitLab variant of HIST-11 waits for C1 | +| T9 (`PV-T9-GEN-003`) | PV-TEST-004 (accepted by PV-REV-TEST-004): the final reconciliation of the decision-equivalence generator, zero CONTRACT_GAP terminals | adopted in 0.2.0: the five repair families it consumes are all implemented, and a finite generator over the nine former gap families holds every cell to the accepted obligation; the T9v2 cell-key vectors are PV-TEST-VECTORS-00n's to deliver | | `PV-REV-ACTIVITY-COVERAGE-001` | the judgement on issue #9 | accepted reading 3, runtime unchanged since 0.1.7; ACT-COV-01..05 adopted in 0.1.9 as `activity` vectors | | `clutter.bands.v1`, `PV-CLUTTER-INCOMPLETE-001` | PV-CLUTTER-INCOMPLETE-001 (accepted by PV-REV-CLUTTER-INCOMPLETE-001 with CLU-INCOMPLETE-01..20), PV-ISSUE-026-RECONCILE-001 (the reading of issue #26 under that contract), PV-REV-PR-031-003 (a PARTIAL count proves a floor only with machine-verifiable subset semantics, CLU-PARTIAL-TRUST-01..08) | accepted; adopted in 0.1.9 as one Clutter rule version with the twenty cases executable; the T7 upper-bound path moved under the same rule id; the subset proof (`value_semantics = OBSERVED_SUBSET_COUNT` in a PARTIAL count's coverage) is part of the same rule version, which 0.1.9 introduces and no stored bundle carries yet | | exact vector families T2/R1/R2, R3, R4, T4/T5/T7 | PV-TEST-VECTORS-002/005/004/007, accepted by PV-REV-TEST-VECTORS-002/005/004/007 | transcribed into `tests/vectors/` in 0.1.9 as the accepted expectations; T5 (`PULSE_ISSUE_ONLY_ACTIVITY`) adopted under `pulse.bands.v1` as the accepted vector requires; T7 (`retention_semantics = UNCLASSIFIED` upper bound) adopted under `clutter.bands.v1`, because the Clutter rule had to move for PV-CLUTTER-INCOMPLETE-001 in the same release and the accepted T7 semantics are unchanged | diff --git a/docs/spec/conformance.md b/docs/spec/conformance.md index 9a8fbd9..1310bc4 100644 --- a/docs/spec/conformance.md +++ b/docs/spec/conformance.md @@ -274,6 +274,31 @@ when its coverage says so. | subset proof | a PARTIAL open count whose coverage is missing, not a subset, an estimate, contradictory or malformed is no lower bound, and Debt is UNKNOWN (issue #39) | `test_a_partial_debt_count_without_the_subset_proof_is_not_a_lower_bound` | | PD1 in the tests | a confirmed open item under partial acquisition is DEGRADED / PRESENT / EXACT, a stale one proves nothing | `test_partial_register_with_observed_items_is_degraded_present`, `test_a_stale_partial_register_proves_nothing_about_now` | +## T9: the decision-equivalence reconciliation (PV-TEST-004, PV-T9-GEN-003) + +`PV-REV-TEST-004` accepted the final T9 reconciliation with zero +`CONTRACT_GAP` terminals: each of the nine gap identifiers of `PV-T9-GEN-002` +maps to one deterministic accepted obligation. 0.2.0 adopts the last of the +repairs those obligations come from, so the implementation side of T9 is a +finite generator over the acquisition tokens and the regions each family +names, which evaluates every cell and holds it to the accepted table +(`tests/conformance/test_t9_reconciliation.py`, about 1,270 cells). The T9v2 +cell-key serialization itself is the research process's to deliver as +vectors. + +| Gap identifier | Accepted obligation | Test | +| --- | --- | --- | +| T9-GAP-HORIZON-PARTIAL-BOUND-001 | PH0..PH2 `UNKNOWN`, PH3 `DEGRADED / EXTENDED / EXACT` (PV-HORIZON-PARTIAL-001) | `test_t9_gap_horizon_partial_bound_001` | +| T9-GAP-CLUTTER-UNAVAILABLE-COMPONENT-001, T9-GAP-CLUTTER-PARTIAL-FORCED-BOUND-001 | a confirmed floor from positive facts only, never `CLEAN` under incomplete coverage (PV-CLUTTER-INCOMPLETE-001) | `test_t9_gap_clutter_unavailable_component_and_partial_forced_bound` | +| T9-GAP-DIRECTION-PARTIAL-LINKAGE-001 | the completion set over k in L..L+R (PV-DIRECTION-INCOMPLETE-001) | `test_t9_gap_direction_partial_linkage_001` | +| T9-GAP-DIRECTION-UNRESOLVED-TARGET-001 | resolved identity LINKED open or closed, positive absence UNLINKED, the rest UNRESOLVED | `test_t9_gap_direction_unresolved_target_001` | +| T9-GAP-INTEGRITY-UNRESOLVED-BAND-001 | `DEGRADED / FAILING / EXACT` only over an established FAILING history, else `UNKNOWN` (PV-INTEGRITY-TOTALITY-001) | `test_t9_gap_integrity_unresolved_band_001` | +| T9-GAP-INTEGRITY-PARTIAL-SUPERSET-001 | the false gap: a PARTIAL required series is `UNKNOWN`, no `possible_bands` (PV-REV-TEST-003) | `test_t9_gap_integrity_partial_superset_001` | +| T9-GAP-INTEGRITY-UNCONFIGURED-RECENT-001 | `UNINSTRUMENTED` with the non-decisive history kept visible | `test_t9_gap_integrity_unconfigured_recent_001` | +| T9-GAP-DEBT-PARTIAL-BOUND-001 | confirmed open `DEGRADED / PRESENT / EXACT`, none confirmed `UNKNOWN` (PV-DEBT-PARTIAL-001) | `test_t9_gap_debt_partial_bound_001` | +| section 4 precedence | unknown history outranks every conservative branch | `test_t9_section_4_unknown_history_outranks_the_unresolved_failing_branch` | +| finiteness | nine identifiers, none without cells | `test_t9_generator_covers_every_family` | + ## Activity coverage (PV-REV-ACTIVITY-COVERAGE-001) Reading 3 of issue #9, accepted: the interval stays the full canonical gap and @@ -511,9 +536,10 @@ accepted case. | rejected | a case expecting refused evidence passes only when the evaluation raises with that code, and the refusal is its whole expectation | `test_a_rejected_case_passes_only_when_the_evidence_is_refused_with_that_code`, `test_rejected_is_the_whole_expectation_and_names_a_code` | | activity kind | the interval and the coverage notes of the activity member are checked | `test_the_activity_kind_checks_the_interval_and_the_coverage_it_discloses` | -Cases not yet implemented as tests (T3, T6, T8, T9, R5..R53, ART-05, +Cases not yet implemented as tests (T3, T6, T8, R5..R53, ART-05, ART-08..ART-11, ART-15, RPT-4..RPT-6, RPT-9) are listed in the ROADMAP under the -synthetic fixture suite. The research process delivers them as executable JSON +synthetic fixture suite; T9 is implemented by its generator since 0.2.0, and +its cell-key vectors are still the research process's to deliver. The research process delivers them as executable JSON vectors in the format of [vectors.md](vectors.md), one accepted family per unit; the ones accepted so far (T2, R1, R2, R3, R4, T4, T5, T7) are in the corpus above, and so are the twenty cases of `PV-CLUTTER-INCOMPLETE-001`, diff --git a/tests/conformance/test_t9_reconciliation.py b/tests/conformance/test_t9_reconciliation.py new file mode 100644 index 0000000..0c857e4 --- /dev/null +++ b/tests/conformance/test_t9_reconciliation.py @@ -0,0 +1,357 @@ +"""T9, the final decision-equivalence reconciliation (PV-TEST-004, generator PV-T9-GEN-003). + +PV-REV-TEST-004 accepted T9 with zero CONTRACT_GAP terminals: each of the nine +gap identifiers PV-T9-GEN-002 exposed maps to exactly one deterministic +accepted obligation (sections 3.1 to 3.9), and higher-precedence fail-closed +conditions stay higher (section 4). This is the implementation side of that +reconciliation: a finite generator over the acquisition tokens (CF, +POS_ABSENT, PARTIAL, UNAVAILABLE, FORBIDDEN, UNKNOWN, ERROR, STALE) and the +threshold and relational regions each former gap family names. Every cell is +evaluated, and must reach the one obligation the accepted table gives it. + +It is not the T9v2 cell-key serialization: the research process delivers that +as vectors (PV-TEST-VECTORS-00n), and each family's accepted fixtures are +already vectors here (HOR-PARTIAL, CLU-INCOMPLETE, DIR-INCOMPLETE, INT-TOTAL, +DEBT-PARTIAL). What this proves is the property T9 exists for: no cell of the +nine families is left without its obligation, and none reaches another one. +The oracles below restate the accepted table, written from the contract text +rather than from the evaluators. +""" + +from __future__ import annotations + +import itertools + +import pytest + +from devostasis.normalize import linkage_state +from devostasis.observations import Observation, ObservationSet +from devostasis.vitals import clutter, debt, direction, horizon, integrity + +OBSERVED_AT = "2026-09-10T12:00:00Z" +SUBSET = {"complete": False, "value_semantics": "OBSERVED_SUBSET_COUNT"} +UNCERTAIN = ("UNAVAILABLE", "FORBIDDEN", "UNKNOWN", "ERROR") +TOKENS = ("CF", "PARTIAL") + UNCERTAIN + ("STALE",) + + +def _obs() -> ObservationSet: + return ObservationSet(subject={"provider": "t9", "immutable_project_id": "t9"}, observed_at=OBSERVED_AT) + + +def _put(obs: ObservationSet, oid: str, token: str, value, value_type: str = "count") -> None: + common = {"provider": "t9", "collected_at": OBSERVED_AT, "source_ref": "t9", "adapter_version": "t9"} + if token == "CF": + obs.add(Observation(oid, "AVAILABLE", value_type, value, **common)) + elif token == "STALE": + obs.add(Observation(oid, "AVAILABLE", value_type, value, freshness="STALE", **common)) + elif token == "PARTIAL": + obs.add(Observation(oid, "PARTIAL", value_type, value, coverage=SUBSET if value_type == "count" else None, reason_code="PAGINATION_CAPPED", **common)) + else: + obs.add(Observation(oid, token, value_type, None, reason_code=f"T9_{token}", **common)) + + +def _outcome(result) -> tuple: + return (result.evaluation_status, result.band, result.band_semantics, result.possible_bands) + + +UNKNOWN = ("UNKNOWN", None, None, None) + + +def _assert_one_obligation(result, expected, cell): + outcome = _outcome(result) + assert outcome[0] in ("AVAILABLE", "DEGRADED", "UNKNOWN"), cell + assert (outcome[1] is None) == (outcome[0] == "UNKNOWN"), f"{cell}: a band exactly when the status is not UNKNOWN" + assert not any("CONTRACT_GAP" in code for code in result.diagnostics), f"{cell}: CONTRACT_GAP is not a terminal" + assert outcome == expected, f"{cell}: expected {expected}, got {outcome}" + + +# --------------------------------------------------------------------------- 3.1 Horizon partial bound + +HORIZON_SHAPES = {"PH0": (0, 0, 0), "PH1": (2, 0, 0), "PH2": (2, 1, 0), "PH3": (2, 1, 1)} +HORIZON_CAPABILITIES = ("SUPPORTED", "UNSUPPORTED", "SUPPORTED_UNUSED") + UNCERTAIN + ("STALE", "PARTIAL") + + +def _horizon_expected(capability: str, token: str, shape: str): + if capability in ("UNSUPPORTED", "SUPPORTED_UNUSED"): + return ("AVAILABLE", "UNDECLARED", "EXACT", None) + if capability != "SUPPORTED": + return UNKNOWN + open_count, future, beyond = HORIZON_SHAPES[shape] + if token == "CF": + band = "EXTENDED" if beyond else "VISIBLE" if future else "DECLARED" if open_count else "UNDECLARED" + return ("AVAILABLE", band, "EXACT", None) + if token == "PARTIAL": + return ("DEGRADED", "EXTENDED", "EXACT", ["EXTENDED"]) if shape == "PH3" else UNKNOWN + return UNKNOWN + + +HORIZON_CELLS = list(itertools.product(HORIZON_CAPABILITIES, TOKENS, HORIZON_SHAPES)) + + +@pytest.mark.parametrize("capability, token, shape", HORIZON_CELLS) +def test_t9_gap_horizon_partial_bound_001(capability, token, shape): + obs = _obs() + if capability in ("SUPPORTED", "UNSUPPORTED", "SUPPORTED_UNUSED"): + _put(obs, horizon.CAPABILITY, "CF", capability, "enum") + elif capability == "STALE": + _put(obs, horizon.CAPABILITY, "STALE", "SUPPORTED", "enum") + elif capability == "PARTIAL": + _put(obs, horizon.CAPABILITY, "PARTIAL", None, "enum") + else: + _put(obs, horizon.CAPABILITY, capability, None, "enum") + for oid, value in zip(horizon.COUNTS, HORIZON_SHAPES[shape]): + _put(obs, oid, token, value) + _assert_one_obligation(horizon.evaluate(obs), _horizon_expected(capability, token, shape), (capability, token, shape)) + + +# --------------------------------------------------------------------------- 3.2 / 3.3 Clutter incomplete components and forced bounds + +CLUTTER_ISSUES = ("CF", "PARTIAL", "UNAVAILABLE", "FORBIDDEN") +CLUTTER_CRS = ("CF", "PARTIAL", "UNAVAILABLE") +CLUTTER_BRANCHES = ("CF", "PARTIAL", "UNAVAILABLE", "STALE") +STALE_WORK = (0, 3, 30) +STALE_BRANCHES = (0, 7, 25) +CLUTTER_CELLS = list(itertools.product(CLUTTER_ISSUES, CLUTTER_CRS, CLUTTER_BRANCHES, STALE_WORK, STALE_BRANCHES)) +DEGRADED_SHAPES = [ + ("DEGRADED", "HEAVY", "EXACT", None), + ("DEGRADED", "CLUTTERED", "CONSERVATIVE_LOWER_BOUND", ["CLUTTERED", "HEAVY"]), + ("DEGRADED", "LIGHT", "CONSERVATIVE_LOWER_BOUND", ["LIGHT", "CLUTTERED", "HEAVY"]), +] + + +@pytest.mark.parametrize("issues, crs, branches, stale_issues, stale_branches", CLUTTER_CELLS) +def test_t9_gap_clutter_unavailable_component_and_partial_forced_bound(issues, crs, branches, stale_issues, stale_branches): + """3.2 and 3.3: an incomplete cell is a confirmed floor from positive facts only, never CLEAN, never zero-filled.""" + obs = _obs() + _put(obs, clutter.ISSUES_OPEN, issues, 100) + _put(obs, clutter.ISSUES_STALE, issues, stale_issues) + _put(obs, clutter.CR_OPEN, crs, 10) + _put(obs, clutter.CR_STALE, crs, 0) + _put(obs, clutter.BRANCHES_STALE, branches, stale_branches) + if branches == "PARTIAL": + _put(obs, clutter.BRANCHES_RETENTION, "CF", "CLASSIFIED", "enum") + result = clutter.evaluate(obs) + outcome = _outcome(result) + cell = (issues, crs, branches, stale_issues, stale_branches) + assert outcome[0] in ("AVAILABLE", "DEGRADED", "UNKNOWN") and (outcome[1] is None) == (outcome[0] == "UNKNOWN"), cell + fail_closed = crs == "UNAVAILABLE" or issues == "FORBIDDEN" or branches == "STALE" + complete = issues == crs == branches == "CF" + if fail_closed: + assert outcome == UNKNOWN, f"{cell}: a required, forbidden or stale component is never bounded around" + return + if complete: + assert outcome[0] == "AVAILABLE" and outcome[2] == "EXACT", cell + return + assert outcome[1] != "CLEAN" and outcome[0] != "AVAILABLE", f"{cell}: incomplete coverage is never exact, never CLEAN" + if outcome[0] == "DEGRADED": + assert outcome in DEGRADED_SHAPES, f"{cell}: {outcome} is not an accepted floor shape" + proven_work = stale_issues if issues in ("CF", "PARTIAL") else 0 + proven_branches = stale_branches if branches in ("CF", "PARTIAL") else 0 + if proven_work >= 25 or proven_branches >= 20: + assert outcome == ("DEGRADED", "HEAVY", "EXACT", None), f"{cell}: a proven HEAVY fact forces HEAVY" + if proven_work == 0 and proven_branches == 0: + assert outcome == UNKNOWN, f"{cell}: without a positive fact there is no floor" + + +# --------------------------------------------------------------------------- 3.4 Direction partial linkage + + +def _direction_band(k: int, n: int) -> str: + return "FULLY_LINKED" if k == n else "MIXED" if 2 * k >= n else "SCATTERED" + + +def _direction_expected(capability: str, n_token: str, n: int, linked: int, unresolved: int): + if n_token != "CF": + return UNKNOWN + if capability == "POS_ABSENT": + return ("AVAILABLE", "UNDECLARED", "EXACT", None) + if capability != "SUPPORTED": + return UNKNOWN + if unresolved == 0: + return ("AVAILABLE", _direction_band(linked, n), "EXACT", None) + reachable = {_direction_band(k, n) for k in range(linked, linked + unresolved + 1)} + if len(reachable) == 1: + band = reachable.pop() + return ("DEGRADED", band, "EXACT", [band]) + return UNKNOWN + + +DIRECTION_SPLITS = [(n, l, u, r) for n in range(1, 6) for l in range(n + 1) for u in range(n + 1 - l) for r in [n - l - u]] +DIRECTION_CELLS = list(itertools.product(("SUPPORTED", "POS_ABSENT", "UNKNOWN", "PARTIAL"), ("CF", "PARTIAL"), DIRECTION_SPLITS)) + + +@pytest.mark.parametrize("capability, n_token, split", DIRECTION_CELLS) +def test_t9_gap_direction_partial_linkage_001(capability, n_token, split): + n, linked, unlinked, unresolved = split + obs = _obs() + if capability == "SUPPORTED": + _put(obs, horizon.CAPABILITY, "CF", "SUPPORTED", "enum") + elif capability == "POS_ABSENT": + _put(obs, horizon.CAPABILITY, "CF", "UNSUPPORTED", "enum") + else: + _put(obs, horizon.CAPABILITY, capability, None, "enum") + _put(obs, direction.ACTIVE, n_token, n) + for oid, value in ((direction.LINKED, linked), (direction.UNLINKED, unlinked), (direction.UNRESOLVED, unresolved)): + _put(obs, oid, n_token, value) + expected = _direction_expected(capability, n_token, n, linked, unresolved) + _assert_one_obligation(direction.evaluate(obs), expected, (capability, n_token, split)) + if unresolved and expected[0] == "DEGRADED": + assert expected[1] != "FULLY_LINKED", "a singleton FULLY_LINKED is impossible while R > 0" + + +# --------------------------------------------------------------------------- 3.5 Direction unresolved target + +REFERENCE_STATES = ("OPEN", "CLOSED", "MISSING", "UNKNOWN", "UNAVAILABLE", "FORBIDDEN", "ERROR", "STALE", None) + + +@pytest.mark.parametrize("state, readable", list(itertools.product(REFERENCE_STATES, (True, False)))) +def test_t9_gap_direction_unresolved_target_001(state, readable): + """Resolved identity is LINKED open or closed, positive absence UNLINKED, anything unresolvable UNRESOLVED.""" + item = {"target_refs": [{"target_id": "T", "state": state}], "linkage_unresolved": [] if readable else ["body:dict"]} + if state in ("OPEN", "CLOSED"): + expected = "LINKED" + elif state == "MISSING" and readable: + expected = "UNLINKED" + else: + expected = "UNRESOLVED" + assert linkage_state(item) == expected + obs = _obs() + _put(obs, horizon.CAPABILITY, "CF", "SUPPORTED", "enum") + for oid, value in ((direction.ACTIVE, 1), (direction.LINKED, int(expected == "LINKED")), (direction.UNLINKED, int(expected == "UNLINKED")), (direction.UNRESOLVED, int(expected == "UNRESOLVED"))): + _put(obs, oid, "CF", value) + outcome = {"LINKED": ("AVAILABLE", "FULLY_LINKED", "EXACT", None), "UNLINKED": ("AVAILABLE", "SCATTERED", "EXACT", None), "UNRESOLVED": UNKNOWN}[expected] + _assert_one_obligation(direction.evaluate(obs), outcome, (state, readable)) + + +# --------------------------------------------------------------------------- 3.6 / 3.7 / 3.8 Integrity + + +def _parent(pid: str, state: str) -> dict: + return { + "parent_id": pid, "kind": "github_actions_workflow_run", "name": "ci", "event": "push", + "current_attempt": 1, "current_state": state, "attempts_observed": [{"attempt": 1, "state": state}], + "attempts_complete": True, "history_state": "", "url": None, + } + + +def _revision(sha: str, day: int, state: str | None) -> dict: + parents = [_parent(f"github_actions:workflow_run:{sha}", state)] if state else [] + history = "FAILURE_OBSERVED" if state == "VERIFY_FAIL" else "PASS_ONLY_OBSERVED" if state == "VERIFY_PASS" else "NO_DECISIVE_OBSERVED" + for parent in parents: + parent["history_state"] = history + contribution = {"FAILURE_OBSERVED": "VERIFY_FAIL", "PASS_ONLY_OBSERVED": "VERIFY_PASS"}.get(history) + return { + "revision": sha, "committed_at": f"2026-08-{day:02d}T10:00:00Z" if day > 27 else f"2026-09-{day:02d}T10:00:00Z", + "parents": parents, "current_verdict": state, "history_state": history, "historical_contribution": contribution, + "history_provenance": "ATTEMPT_LEVEL" if parents else None, "history_complete": True, + } + + +def _integrity(configured, revisions, series_token="CF"): + obs = _obs() + if configured is not None: + _put(obs, integrity.CONFIGURED, "CF", configured, "boolean") + _put(obs, integrity.REVISIONS, series_token, revisions, "series") + return integrity.evaluate(obs) + + +def _history(n: int, f: int) -> list[dict]: + return [_revision(f"d{i}", 1 + i, "VERIFY_FAIL" if i < f else "VERIFY_PASS") for i in range(n)] + + +INTEGRITY_REGIONS = [(n, f) for n in range(0, 7) for f in range(0, n + 1)] + + +@pytest.mark.parametrize("n, f", INTEGRITY_REGIONS) +def test_t9_gap_integrity_unresolved_band_001(n, f): + """3.6: a newest VERIFY_UNRESOLVED names FAILING only where the established predicate already holds.""" + revisions = _history(n, f) + [_revision("newest", 9, "VERIFY_UNRESOLVED")] + established = n >= 4 and 4 * f >= n + expected = ("DEGRADED", "FAILING", "EXACT", None) if established else UNKNOWN + _assert_one_obligation(_integrity(True, revisions), expected, (n, f)) + + +@pytest.mark.parametrize("n, f", INTEGRITY_REGIONS) +def test_t9_gap_integrity_partial_superset_001(n, f): + """3.7, the false gap: a PARTIAL required series is UNKNOWN with no band and no possible_bands, whatever it holds.""" + revisions = _history(n, f) + [_revision("newest", 9, "VERIFY_UNRESOLVED")] + _assert_one_obligation(_integrity(True, revisions, "PARTIAL"), UNKNOWN, (n, f)) + _assert_one_obligation(_integrity(True, _history(n, f), "PARTIAL"), UNKNOWN, (n, f, "decided")) + + +@pytest.mark.parametrize("states", [("NON_VERIFY_TERMINAL",), ("NOT_EXECUTED",), ("NOT_EXECUTED", "NON_VERIFY_TERMINAL"), (None, "NOT_EXECUTED")]) +def test_t9_gap_integrity_unconfigured_recent_001(states): + """3.8: positively unconfigured with only non-decisive recent evidence is exactly UNINSTRUMENTED, the evidence kept.""" + revisions = [_revision(f"r{i}", 3 + i, state) for i, state in enumerate(states)] + result = _integrity(False, revisions) + _assert_one_obligation(result, ("AVAILABLE", "UNINSTRUMENTED", "EXACT", None), states) + assert "CI_UNINSTRUMENTED_WITH_RECENT_NONDECISIVE_HISTORY" in result.diagnostics + _assert_one_obligation(_integrity(True, revisions), ("AVAILABLE", "NO_DECISIVE_RUNS", "EXACT", None), (states, "configured")) + newest_unknown = revisions + [_revision("newest", 9, "UNKNOWN")] + _assert_one_obligation(_integrity(False, newest_unknown), UNKNOWN, (states, "newest UNKNOWN")) + + +def test_t9_section_4_unknown_history_outranks_the_unresolved_failing_branch(): + """Section 4: unknown history is never routed through a conservative branch to obtain a band.""" + suite = { + "parent_id": "github_checks:check_suite:1", "kind": "github_check_suite", "name": "app", "event": None, + "current_attempt": None, "current_state": "VERIFY_PASS", "attempts_observed": [{"attempt": None, "state": "VERIFY_PASS"}], + "attempts_complete": False, "history_state": "UNKNOWN_HISTORY", "url": None, + } + unknown_revision = { + "revision": "suite", "committed_at": "2026-09-07T10:00:00Z", "parents": [suite], "current_verdict": "VERIFY_PASS", + "history_state": "UNKNOWN_HISTORY", "historical_contribution": None, "history_provenance": "PARENT_LEVEL_ONLY", "history_complete": False, + } + established = _history(6, 3) + [unknown_revision, _revision("newest", 9, "VERIFY_UNRESOLVED")] + assert _outcome(_integrity(True, _history(6, 3) + [_revision("newest", 9, "VERIFY_UNRESOLVED")]))[1] == "FAILING" + _assert_one_obligation(_integrity(True, established), UNKNOWN, "established FAILING beside unknown history") + + +# --------------------------------------------------------------------------- 3.9 Debt partial bound + +DEBT_CAPABILITIES = ("CONFIGURED", "UNCONFIGURED", "UNKNOWN", "FORBIDDEN", "STALE") +DEBT_CELLS = list(itertools.product(DEBT_CAPABILITIES, TOKENS, (0, 3))) + + +def _debt_expected(capability: str, token: str, confirmed: int): + if capability == "UNCONFIGURED": + return ("AVAILABLE", "UNINSTRUMENTED", "EXACT", None) + if capability != "CONFIGURED": + return UNKNOWN + if token == "CF": + return ("AVAILABLE", "PRESENT" if confirmed else "CLEAR", "EXACT", None) + if token == "PARTIAL": + return ("DEGRADED", "PRESENT", "EXACT", None) if confirmed else UNKNOWN + return UNKNOWN + + +@pytest.mark.parametrize("capability, token, confirmed", DEBT_CELLS) +def test_t9_gap_debt_partial_bound_001(capability, token, confirmed): + obs = _obs() + if capability in ("CONFIGURED", "UNCONFIGURED"): + _put(obs, debt.CAPABILITY, "CF", capability, "enum") + elif capability == "STALE": + _put(obs, debt.CAPABILITY, "STALE", "CONFIGURED", "enum") + else: + _put(obs, debt.CAPABILITY, capability, None, "enum") + _put(obs, debt.MAPPING, "CF", {"source": "labels", "labels": ["debt"], "mapping_version": "1"}, "record") + _put(obs, debt.OPEN, token, confirmed) + result = debt.evaluate(obs) + _assert_one_obligation(result, _debt_expected(capability, token, confirmed), (capability, token, confirmed)) + assert result.band != "ACCUMULATED" + + +def test_t9_generator_covers_every_family(): + """The generator is finite and names every former gap family: nine identifiers, none left without cells.""" + families = { + "T9-GAP-HORIZON-PARTIAL-BOUND-001": len(HORIZON_CELLS), + "T9-GAP-CLUTTER-UNAVAILABLE-COMPONENT-001": len(CLUTTER_CELLS), + "T9-GAP-CLUTTER-PARTIAL-FORCED-BOUND-001": len(CLUTTER_CELLS), + "T9-GAP-DIRECTION-PARTIAL-LINKAGE-001": len(DIRECTION_CELLS), + "T9-GAP-DIRECTION-UNRESOLVED-TARGET-001": len(REFERENCE_STATES) * 2, + "T9-GAP-INTEGRITY-UNRESOLVED-BAND-001": len(INTEGRITY_REGIONS), + "T9-GAP-INTEGRITY-PARTIAL-SUPERSET-001": len(INTEGRITY_REGIONS), + "T9-GAP-INTEGRITY-UNCONFIGURED-RECENT-001": 4, + "T9-GAP-DEBT-PARTIAL-BOUND-001": len(DEBT_CELLS), + } + assert len(families) == 9 and all(count > 0 for count in families.values()) From 9d14ca7938337d58df4b332f96ac2b9dbece872d Mon Sep 17 00:00:00 2001 From: Dreven Date: Thu, 1 Oct 2026 01:20:53 +0300 Subject: [PATCH 07/33] Keep the durable history carrier to what a build can consume The live run against a copy of the fleet store showed two costs the tests could not. The first 0.2.0 bundle of trade_simulation carried its 0.1.9 predecessor's whole revision series, about a megabyte, although every record in it had aged out of the 14-day window and would be dropped on arrival. And its snapshot grew from 12 KB to 393 KB, because 834 workflow_dispatch runs in two weeks attach to a handful of revisions and the carrier wrote each run as its own object. The carried observation now holds only in-window records, and a replayed 0.1.x record only the fields replay reads (revision, time and every parent's identity, kind and attempts). The carrier stores parents grouped by shape (kind, greatest attempt, observed attempts) with their ids, which is lossless: every parent keeps its identity and its own attempts, and a parent listed in two groups is corrupt history. On the same evidence the snapshot is 91 KB. Which of those runs should count as verification at all is the question issue #40 already asks the research side. Co-Authored-By: Claude Opus 5.5 --- docs/spec/integrity-ci.md | 16 +++-- src/devostasis/revision_history.py | 90 +++++++++++++++++++++--- tests/test_revision_history.py | 40 +++++++++++ tests/vectors/revision-history.json | 102 ++++++++++++++++++---------- 4 files changed, 198 insertions(+), 50 deletions(-) diff --git a/docs/spec/integrity-ci.md b/docs/spec/integrity-ci.md index ed2fef0..c36829e 100644 --- a/docs/spec/integrity-ci.md +++ b/docs/spec/integrity-ci.md @@ -104,12 +104,16 @@ PV-REV-HIST-002, target B3), under the history-semantics lineage `devostasis.ci-history.v2`. **The carrier** is the union of every attempt observed per parent per -revision: `{revision, committed_at, parents: [{parent_id, kind, -latest_attempt, attempts: [{attempt, state}]}], history_state, -historical_contribution, history_complete}`. Each bundle's union is its -predecessor's union plus what the provider shows now, so it holds every -history fact of the chain and selects none (the complete-chain-equivalent -reduction of section C). It lives in two canonical places: +revision: `{revision, committed_at, history_state, historical_contribution, +history_complete, parent_groups: [{kind, latest_attempt, attempts: +[{attempt, state}], parent_ids}]}`. Parents of one shape are stored once +with their ids, which loses nothing and keeps the carrier small where a busy +default branch attaches hundreds of single-attempt runs to one revision (the +most active project of the fleet: 91 KB of snapshot instead of 393 KB). +Each bundle's union is its predecessor's union plus what the provider shows +now, so it holds every history fact of the chain and selects none (the +complete-chain-equivalent reduction of section C). Records that have aged +out of the window are not carried. It lives in two canonical places: - `derived.revision_history` of the Integrity result in `snapshot.json`, `{lineage, source, records}`: the durable output, present on every diff --git a/src/devostasis/revision_history.py b/src/devostasis/revision_history.py index dbbddb3..518e203 100644 --- a/src/devostasis/revision_history.py +++ b/src/devostasis/revision_history.py @@ -126,11 +126,40 @@ def parent_from_current(parent: Any, where: str) -> dict[str, Any]: return _parent(parent.get("parent_id"), parent.get("kind"), parent.get("current_attempt"), parent.get("attempts_observed"), where) -def parent_from_durable(parent: Any, where: str) -> dict[str, Any]: - """A parent of a carried union record.""" - if not isinstance(parent, dict): - raise HistoryShapeError(f"{where}: a parent is not an object") - return _parent(parent.get("parent_id"), parent.get("kind"), parent.get("latest_attempt"), parent.get("attempts"), where) +def parents_from_groups(groups: Any, where: str) -> list[dict[str, Any]]: + """The parents of a carried union record, which the carrier stores grouped by shape.""" + if not isinstance(groups, list): + raise HistoryShapeError(f"{where}: parent_groups is not a list") + parents: list[dict[str, Any]] = [] + seen: set[str] = set() + for group in groups: + if not isinstance(group, dict) or not isinstance(group.get("parent_ids"), list) or not group["parent_ids"]: + raise HistoryShapeError(f"{where}: a parent group names no parents") + for parent_id in group["parent_ids"]: + if parent_id in seen: + raise HistoryShapeError(f"{where}: parent {parent_id} is in two groups") + seen.add(parent_id) + parents.append(_parent(parent_id, group.get("kind"), group.get("latest_attempt"), group.get("attempts"), where)) + return parents + + +def parent_groups(parents: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Parents of one shape (kind, greatest attempt, observed attempts) stored once with their ids. + + A busy default branch attaches hundreds of single-attempt runs to one + revision; written one object per run, the carrier of such a repository + was hundreds of kilobytes. The grouping loses nothing: every parent keeps + its identity and exactly its own attempts. + """ + groups: dict[tuple, dict[str, Any]] = {} + for parent in parents: + key = (parent["kind"], -1 if parent["latest_attempt"] is None else parent["latest_attempt"], tuple(_attempt_key(a) for a in parent["attempts"])) + group = groups.setdefault(key, {"kind": parent["kind"], "latest_attempt": parent["latest_attempt"], "attempts": parent["attempts"], "parent_ids": []}) + group["parent_ids"].append(parent["parent_id"]) + ordered = [groups[key] for key in sorted(groups)] + for group in ordered: + group["parent_ids"] = sorted(group["parent_ids"]) + return ordered def merge_parents(*groups: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[str]]: @@ -188,6 +217,13 @@ def union_record(revision: str, committed_at: str, parents: list[dict[str, Any]] return record +def encode(record: dict[str, Any]) -> dict[str, Any]: + """A union record as the carrier stores it: its parents grouped by shape.""" + encoded = {key: value for key, value in record.items() if key != "parents"} + encoded["parent_groups"] = parent_groups(record["parents"]) + return encoded + + def current_history(parents: list[dict[str, Any]]) -> tuple[str, bool]: """The state current evidence proves on its own (no durable history), for the per-bundle revision record.""" durable = [parent_from_current(p, "current") for p in parents] @@ -218,7 +254,7 @@ def _carried_records(value: Any) -> tuple[list[dict[str, Any]], dict[str, Any]]: raise HistoryShapeError(f"{where}: no revision or committed_at") timeutil.parse_ts(record["committed_at"]) if lineage == LINEAGE: - parents = [parent_from_durable(p, where) for p in record.get("parents") or []] + parents = parents_from_groups(record.get("parent_groups") or [], where) unresolved = record.get("unresolved") or [] if not isinstance(unresolved, list) or not all(isinstance(reason, str) for reason in unresolved): raise HistoryShapeError(f"{where}: unresolved is not a list of reasons") @@ -256,7 +292,7 @@ def output(self) -> dict[str, Any]: """ bearing = [r for r in self.records.values() if r["parents"] or r.get("unresolved")] ordered = sorted(bearing, key=lambda r: (r["committed_at"], r["revision"])) - return {"lineage": LINEAGE, "source": dict(self.source), "records": ordered} + 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]: @@ -328,6 +364,41 @@ def reconcile(obs: ObservationSet) -> Reconciled: # --------------------------------------------------------------------------- the carried observation +def _in_window(records: Any, observed_at: str) -> Any: + """The records a build can consume: those still inside the 14-day window it evaluates. + + A record outside the window would be dropped by the reconciliation anyway + (HIST-10), so carrying it adds bytes and no evidence. A record whose time + cannot be read is kept, so the reconciliation reports it as malformed + instead of it disappearing here. + """ + if not isinstance(records, list): + return records + window_start = timeutil.minus_days(timeutil.parse_ts(observed_at), INTEGRITY["window_days"]) + kept = [] + for record in records: + try: + if timeutil.parse_ts(record["committed_at"]) < window_start: + continue + except (KeyError, TypeError, ValueError): + pass + kept.append(record) + return kept + + +def _replay_source(record: Any) -> Any: + """A 0.1.x revision record reduced to what its replay reads: identity, time and every observed attempt.""" + if not isinstance(record, dict) or not isinstance(record.get("parents"), list): + return record + parents = [] + for parent in record["parents"]: + if not isinstance(parent, dict): + parents.append(parent) + continue + parents.append({key: parent.get(key) for key in ("parent_id", "kind", "current_attempt", "attempts_observed")}) + return {"revision": record.get("revision"), "committed_at": record.get("committed_at"), "parents": parents} + + def carried_observation( comparison_status: str, previous_bundle_id: str | None, @@ -361,7 +432,7 @@ def carried_observation( integrity = next((v for v in previous_snapshot.get("vitals") or [] if isinstance(v, dict) and v.get("vital_id") == "integrity"), None) durable = (integrity or {}).get("derived", {}).get("revision_history") if isinstance((integrity or {}).get("derived"), dict) else None if isinstance(durable, dict) and "lineage" in durable: - value = {"lineage": durable.get("lineage"), "records": durable.get("records"), "basis": BASIS_SNAPSHOT, **source} + value = {"lineage": durable.get("lineage"), "records": _in_window(durable.get("records"), observed_at), "basis": BASIS_SNAPSHOT, **source} return Observation(observation_id=CARRIED, status=AVAILABLE, value_type="record", value=value, evidence_ref=evidence, **common) series = None if isinstance(previous_observations, dict): @@ -369,7 +440,8 @@ def carried_observation( if series is not None: # A bundle older than this carrier: replay its revision records. records = series.get("value") if series.get("status") in (AVAILABLE, PARTIAL) and isinstance(series.get("value"), list) else [] - value = {"lineage": REPLAYABLE_LINEAGE, "records": records, "basis": BASIS_SERIES, "source_series_status": series.get("status"), **source} + replayable = [_replay_source(record) for record in _in_window(records, observed_at)] + value = {"lineage": REPLAYABLE_LINEAGE, "records": replayable, "basis": BASIS_SERIES, "source_series_status": series.get("status"), **source} return Observation(observation_id=CARRIED, status=AVAILABLE, value_type="record", value=value, evidence_ref=evidence, **common) return Observation(observation_id=CARRIED, status=UNKNOWN, value_type="record", reason_code=PREDECESSOR_CARRIES_NO_HISTORY, evidence_ref=evidence, **common) diff --git a/tests/test_revision_history.py b/tests/test_revision_history.py index 0f89ef4..8bfe3ed 100644 --- a/tests/test_revision_history.py +++ b/tests/test_revision_history.py @@ -266,3 +266,43 @@ def test_verify_names_carried_history_that_is_not_from_the_previous_bundle(tmp_p assert _check_history_source(b2.members, b2.manifest, observations) == [] problems = _check_history_source(b2.members, dict(b2.manifest, previous_bundle_id="c" * 64), observations) assert len(problems) == 2 and all(problem.startswith("HISTORY_SOURCE_MISMATCH") for problem in problems) + + +def test_the_carrier_holds_only_what_the_build_can_consume(tmp_path): + """Records that have aged out of the window are not carried, and replayed 0.1.x records keep only what replay reads.""" + store = FilesystemHistoryStore(tmp_path) + project = single_project(LOCATOR, config_version="1") + older = _older_bundle(project, _obs("2026-09-06T12:00:00Z", _passing([1, 2, 3]) + [_record("r", 4, [_run(RUN_R, [(1, "VERIFY_FAIL")])]), _record("s", 5, [_run(RUN_R + 1, [(1, "VERIFY_PASS")])])])) + store.commit(older) + # Observed on 2026-09-19 the window starts at 09-05T12:00: r (09-04) has aged out, s (09-05T10:00) too, nothing else is carried. + b2, _ = _build(store, "2026-09-19T12:00:00Z", _passing([6, 7])) + assert _carried(b2)["value"]["records"] == [] + # On 2026-09-20 the window starts at 09-06T12:00, so p6 (09-06T10:00) is no longer carried either. + b3, _ = _build(store, "2026-09-20T12:00:00Z", _passing([6, 7, 8])) + assert [r["revision"] for r in _carried(b3)["value"]["records"]] == ["p7"] + b4_store = FilesystemHistoryStore(tmp_path / "replay") + b4_store.commit(older) + b4, _ = _build(b4_store, "2026-09-10T12:00:00Z", _passing([6])) + replayed = _carried(b4)["value"]["records"] + assert {r["revision"] for r in replayed} == {"p1", "p2", "p3", "r", "s"} + assert all(set(r) == {"revision", "committed_at", "parents"} for r in replayed) + assert all(set(p) == {"parent_id", "kind", "current_attempt", "attempts_observed"} for r in replayed for p in r["parents"]) + + +def test_the_carrier_groups_parents_by_shape_without_losing_one(): + """Hundreds of single-attempt runs on one revision are stored once per shape, and every parent comes back.""" + from devostasis.revision_history import HistoryShapeError, parent_groups, parents_from_groups + + parents = [ + {"parent_id": f"github_actions:workflow_run:{n}", "kind": "github_actions_workflow_run", "latest_attempt": 1, "attempts": [{"attempt": 1, "state": "VERIFY_PASS"}]} + for n in range(300) + ] + [ + {"parent_id": "github_actions:workflow_run:900", "kind": "github_actions_workflow_run", "latest_attempt": 2, "attempts": [{"attempt": 1, "state": "VERIFY_FAIL"}, {"attempt": 2, "state": "VERIFY_PASS"}]}, + {"parent_id": "github_checks:check_suite:7", "kind": "github_check_suite", "latest_attempt": None, "attempts": [{"attempt": None, "state": "VERIFY_PASS"}]}, + ] + groups = parent_groups(parents) + assert len(groups) == 3 and sum(len(group["parent_ids"]) for group in groups) == 302 + restored = parents_from_groups(groups, "test") + assert sorted(restored, key=lambda p: p["parent_id"]) == sorted(parents, key=lambda p: p["parent_id"]) + with pytest.raises(HistoryShapeError): + parents_from_groups([groups[0], {**groups[0]}], "test") diff --git a/tests/vectors/revision-history.json b/tests/vectors/revision-history.json index 8c40bd5..63805a7 100644 --- a/tests/vectors/revision-history.json +++ b/tests/vectors/revision-history.json @@ -171,9 +171,8 @@ { "revision": "r", "committed_at": "2026-09-06T10:00:00Z", - "parents": [ + "parent_groups": [ { - "parent_id": "github_checks:check_suite:41", "kind": "github_check_suite", "latest_attempt": null, "attempts": [ @@ -181,6 +180,9 @@ "attempt": null, "state": "VERIFY_FAIL" } + ], + "parent_ids": [ + "github_checks:check_suite:41" ] } ] @@ -384,9 +386,8 @@ { "revision": "r", "committed_at": "2026-09-01T10:00:00Z", - "parents": [ + "parent_groups": [ { - "parent_id": "github_actions:workflow_run:41", "kind": "github_actions_workflow_run", "latest_attempt": 1, "attempts": [ @@ -394,6 +395,9 @@ "attempt": 1, "state": "VERIFY_PASS" } + ], + "parent_ids": [ + "github_actions:workflow_run:41" ] } ] @@ -479,9 +483,8 @@ { "revision": "r", "committed_at": "2026-09-06T10:00:00Z", - "parents": [ + "parent_groups": [ { - "parent_id": "github_actions:workflow_run:41", "kind": "github_actions_workflow_run", "latest_attempt": 1, "attempts": [ @@ -489,6 +492,9 @@ "attempt": 1, "state": "VERIFY_FAIL" } + ], + "parent_ids": [ + "github_actions:workflow_run:41" ] } ] @@ -568,9 +574,8 @@ { "revision": "r", "committed_at": "2026-09-06T10:00:00Z", - "parents": [ + "parent_groups": [ { - "parent_id": "github_actions:workflow_run:41", "kind": "github_actions_workflow_run", "latest_attempt": 1, "attempts": [ @@ -578,6 +583,9 @@ "attempt": 1, "state": "VERIFY_FAIL" } + ], + "parent_ids": [ + "github_actions:workflow_run:41" ] } ] @@ -657,9 +665,8 @@ { "revision": "r", "committed_at": "2026-09-06T10:00:00Z", - "parents": [ + "parent_groups": [ { - "parent_id": "github_actions:workflow_run:41", "kind": "github_actions_workflow_run", "latest_attempt": 6, "attempts": [ @@ -687,6 +694,9 @@ "attempt": 1, "state": "VERIFY_FAIL" } + ], + "parent_ids": [ + "github_actions:workflow_run:41" ] } ] @@ -781,9 +791,8 @@ { "revision": "r", "committed_at": "2026-09-06T10:00:00Z", - "parents": [ + "parent_groups": [ { - "parent_id": "github_actions:workflow_run:41", "kind": "github_actions_workflow_run", "latest_attempt": 1, "attempts": [ @@ -791,6 +800,9 @@ "attempt": 1, "state": "VERIFY_FAIL" } + ], + "parent_ids": [ + "github_actions:workflow_run:41" ] } ] @@ -968,9 +980,8 @@ { "revision": "r", "committed_at": "2026-09-06T10:00:00Z", - "parents": [ + "parent_groups": [ { - "parent_id": "github_actions:workflow_run:41", "kind": "github_actions_workflow_run", "latest_attempt": 1, "attempts": [ @@ -978,6 +989,9 @@ "attempt": 1, "state": "VERIFY_FAIL" } + ], + "parent_ids": [ + "github_actions:workflow_run:41" ] } ] @@ -1691,9 +1705,8 @@ { "revision": "p1", "committed_at": "2026-09-01T10:00:00Z", - "parents": [ + "parent_groups": [ { - "parent_id": "github_actions:workflow_run:901", "kind": "github_actions_workflow_run", "latest_attempt": 1, "attempts": [ @@ -1701,6 +1714,9 @@ "attempt": 1, "state": "VERIFY_PASS" } + ], + "parent_ids": [ + "github_actions:workflow_run:901" ] } ] @@ -1897,9 +1913,8 @@ { "revision": "r", "committed_at": "2026-09-01T10:00:00Z", - "parents": [ + "parent_groups": [ { - "parent_id": "github_actions:workflow_run:41", "kind": "github_actions_workflow_run", "latest_attempt": 1, "attempts": [ @@ -1907,6 +1922,9 @@ "attempt": 1, "state": "VERIFY_FAIL" } + ], + "parent_ids": [ + "github_actions:workflow_run:41" ] } ] @@ -2072,9 +2090,8 @@ { "revision": "old", "committed_at": "2026-08-20T10:00:00Z", - "parents": [ + "parent_groups": [ { - "parent_id": "github_actions:workflow_run:40", "kind": "github_actions_workflow_run", "latest_attempt": 1, "attempts": [ @@ -2082,6 +2099,9 @@ "attempt": 1, "state": "VERIFY_FAIL" } + ], + "parent_ids": [ + "github_actions:workflow_run:40" ] } ] @@ -2280,9 +2300,8 @@ { "revision": "r", "committed_at": "2026-09-06T10:00:00Z", - "parents": [ + "parent_groups": [ { - "parent_id": "github_actions:workflow_run:41", "kind": "github_actions_workflow_run", "latest_attempt": 1, "attempts": [ @@ -2290,6 +2309,9 @@ "attempt": 1, "state": "VERIFY_FAIL" } + ], + "parent_ids": [ + "github_actions:workflow_run:41" ] } ] @@ -2465,9 +2487,8 @@ { "revision": "r", "committed_at": "2026-09-06T10:00:00Z", - "parents": [ + "parent_groups": [ { - "parent_id": "github_actions:workflow_run:41", "kind": "github_actions_workflow_run", "latest_attempt": 2, "attempts": [ @@ -2479,6 +2500,9 @@ "attempt": 1, "state": "VERIFY_FAIL" } + ], + "parent_ids": [ + "github_actions:workflow_run:41" ] } ] @@ -2654,9 +2678,8 @@ { "revision": "r", "committed_at": "2026-09-06T10:00:00Z", - "parents": [ + "parent_groups": [ { - "parent_id": "github_checks:check_suite:41", "kind": "github_check_suite", "latest_attempt": null, "attempts": [ @@ -2664,6 +2687,9 @@ "attempt": null, "state": "VERIFY_FAIL" } + ], + "parent_ids": [ + "github_checks:check_suite:41" ] } ] @@ -2987,7 +3013,7 @@ { "revision": "r", "committed_at": "2026-09-06T10:00:00Z", - "parents": [] + "parent_groups": [] } ], "source_bundle_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", @@ -3160,7 +3186,7 @@ { "revision": "r", "committed_at": "2026-09-06T10:00:00Z", - "parents": [] + "parent_groups": [] } ], "source_bundle_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", @@ -3337,9 +3363,8 @@ { "revision": "r", "committed_at": "2026-09-09T10:00:00Z", - "parents": [ + "parent_groups": [ { - "parent_id": "github_actions:workflow_run:41", "kind": "github_actions_workflow_run", "latest_attempt": 1, "attempts": [ @@ -3347,6 +3372,9 @@ "attempt": 1, "state": "VERIFY_FAIL" } + ], + "parent_ids": [ + "github_actions:workflow_run:41" ] } ] @@ -3550,9 +3578,8 @@ { "revision": "r", "committed_at": "2026-09-06T10:00:00Z", - "parents": [ + "parent_groups": [ { - "parent_id": "github_actions:workflow_run:41", "kind": "github_actions_workflow_run", "latest_attempt": 3, "attempts": [ @@ -3560,6 +3587,9 @@ "attempt": 3, "state": "VERIFY_PASS" } + ], + "parent_ids": [ + "github_actions:workflow_run:41" ] } ] @@ -3735,9 +3765,8 @@ { "revision": "r", "committed_at": "2026-09-06T10:00:00Z", - "parents": [ + "parent_groups": [ { - "parent_id": "github_checks:check_suite:41", "kind": "github_check_suite", "latest_attempt": null, "attempts": [ @@ -3745,6 +3774,9 @@ "attempt": null, "state": "VERIFY_UNRESOLVED" } + ], + "parent_ids": [ + "github_checks:check_suite:41" ] } ] @@ -3902,7 +3934,7 @@ { "revision": "r", "committed_at": "2026-09-06T10:00:00Z", - "parents": [], + "parent_groups": [], "unresolved": [ "LINEAGE_INCOMPATIBLE:devostasis.ci-history.v9" ] From 7d2e1d87f703d9780395101316d7fa6a05dc964e Mon Sep 17 00:00:00 2001 From: Dreven Date: Thu, 1 Oct 2026 01:24:34 +0300 Subject: [PATCH 08/33] Release 0.2.0: date the adoption release and reconcile the roadmap Version 0.2.0, the pins that name it, the example bundle regenerated under the four new rule versions (its change-request records now in the shape the adapter writes, one merged change linked to the closed release it delivered, so the example shows the state-neutral linkage: 6 of 10 linked where the open-target count says 5), and the changelog section. The roadmap moves B3 to done, the Direction, Horizon, Debt and T9 judgements to adopted, finding 9 to judged, and the count of named cases without a test from 63 to 62. It also says plainly that 0.2.0 ships before the condition it had set for 0.2 (Phase B closed, an outside consumer, no unadopted research): the owner asked for a large release (#47), and this is the part of it that rests on accepted contracts, so B7 and the Phase C contracts become the bar for 0.3. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/observe-self.yml | 4 +- CHANGELOG.md | 119 +++++++++++ README.md | 10 +- ROADMAP.md | 67 +++--- docs/deployment.md | 4 +- examples/observations.json | 62 +++++- examples/sample-bundle/demand.json | 2 +- examples/sample-bundle/gauges.json | 2 +- examples/sample-bundle/manifest.json | 22 +- examples/sample-bundle/observations.json | 214 +++++++++++++++++++- examples/sample-bundle/report.md | 34 ++-- examples/sample-bundle/snapshot.json | 246 +++++++++++++++++++++-- pyproject.toml | 2 +- scripts/make_examples.py | 20 +- src/devostasis/__init__.py | 2 +- src/devostasis/contracts.py | 10 +- 16 files changed, 726 insertions(+), 94 deletions(-) diff --git a/.github/workflows/observe-self.yml b/.github/workflows/observe-self.yml index a65361a..c444ea4 100644 --- a/.github/workflows/observe-self.yml +++ b/.github/workflows/observe-self.yml @@ -4,7 +4,7 @@ # # jobs: # vitals: -# uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.1.9 +# uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.2.0 # with: # debt-labels: "type:debt" # @@ -24,7 +24,7 @@ on: devostasis-ref: description: Git ref of Devostasis to install (tag, branch or commit) type: string - default: v0.1.9 + default: v0.2.0 planning-source: description: milestones, file or none type: string diff --git a/CHANGELOG.md b/CHANGELOG.md index 9609d56..d200e41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,125 @@ 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) + +The adoption release. Every accepted research contract about the Vitals and +their history that this repository had not adopted, and that needs no owner +decision, is adopted: the repairs of Direction, Horizon and Debt that issue +#33 listed, the final T9 reconciliation, and target B3, durable Integrity +revision history (`PV-HIST-002`, #34). The owner asked for a large next +release on 2026-09-30 (#47); the candidate specification written for that +request (`PV-RELEASE-020-001`, "Evidence Observatory") has not been accepted +yet, so 0.2.0 is the part of that ambition that rests on accepted contracts. +No threshold, window, gauge constant or demand mapping changed. Four rule ids +moved, each with the judgement that moved it, so the first 0.2.0 bundle of +every project compares `INCOMPARABLE` (`RULE_VERSION_BOUNDARY`) on Horizon, +Direction, Integrity and Debt and stays comparable on the rest. + +- **Direction `direction.bands.v2`: state-neutral linkage, and incomplete + linkage without failing the project.** `PV-REV-DIRECTION-CLOSED-TARGET-001` + (cases `DIR-CLOSED-01..09`): an active change request linked to a declared + target stays linked when the target closes. Calibration finding 9 is + repaired: closing a delivered target used to un-link the work that + delivered it, so never closing targets kept the band higher than finishing + them. Measured on a copy of the fleet store: this repository reads `MIXED`, + 9 of 18 active change requests traced to a declared target, where the + open-target count said 4 of 18, `SCATTERED`. That count is still derived, + for presentation only. `PV-DIRECTION-INCOMPLETE-001` (accepted by + `PV-REV-DIRECTION-INCOMPLETE-001`, cases `DIR-INCOMPLETE-01..16`): every + active change request is `LINKED`, `UNLINKED` or `UNRESOLVED`. A reference + the complete register lacks is a broken reference, unlinked and diagnosed + `DIRECTION_TARGET_REFERENCE_MISSING`; a reference nobody could resolve, or a + title, body or milestone of the wrong type, is unresolved. Over + `L + U + R = N` the completions `k = L..L+R` go through the unchanged + predicates: one reachable band is `DEGRADED / / EXACT`, several are + `UNKNOWN` with the reachable set in `derived`. Unreadable linkage evidence no + longer fails the whole project: `LinkageEvidenceError` is gone, and a change + request names what it could not read in `linkage_unresolved`. A partial + target list no longer blocks Direction when every referenced target resolves + on its own, as a milestone embedded in the change request does. +- **Target markers are standalone tokens** (`PV-AUDIT-TARGET-MARKER-SYNTAX-001`). + `SubTarget: B1`, `NotTarget: B1` and `PreTarget:B1` linked their change + request to B1, because the configured marker was matched as a substring. + The marker now starts the text or follows a character that cannot continue + a word, and a marker ending in a letter needs a boundary after it too. The + audit's regressions `LINK-MARKER-01..08` reached this repository only as + categories; the tests cover each category and say so. +- **Horizon `horizon.bands.v2`: partial target enumeration** + (`PV-HORIZON-PARTIAL-001`, accepted by `PV-REV-HORIZON-PARTIAL-001`, cases + `HOR-PARTIAL-01..16`). A partial enumeration that returned a target proves + the capability; its counts are observed-subset lower bounds with the proof + #39 asked for; only an open target already beyond the frame forces a band, + `DEGRADED / EXTENDED / EXACT`. Every other partial shape stays `UNKNOWN`, + because Horizon declares no order to pick a representative by, and a + partial zero is never `UNDECLARED`. +- **Debt `debt.bands.v2`: partial register** (`PV-DEBT-PARTIAL-001`, accepted + by `PV-REV-DEBT-PARTIAL-001`, cases `DEBT-PARTIAL-01..16`). A confirmed open + item under a partial enumeration is `DEGRADED / PRESENT / EXACT`, the band + exact and the count a lower bound, none confirmed is `UNKNOWN`, and a + `PARTIAL` count is read as a lower bound only with its + `OBSERVED_SUBSET_COUNT` proof (the Debt part of #39). Before, a partial + positive count was `CONSERVATIVE_LOWER_BOUND` on its status alone, so an + estimate could prove `PRESENT`. +- **Integrity `integrity.bands.v1+ci-unit-004+hist-002`: durable revision + history** (`PV-HIST-002`, accepted by `PV-REV-HIST-002`, target B3). History + was reconstructed from whatever the provider still showed, so a failure + hidden behind a passing re-request of a check suite, or dropped by + retention, vanished from the counts. The union of every attempt observed per + parent per revision is now carried from bundle to bundle: a build adds its + immediate predecessor's union as `ci.revision_history_carried`, Integrity + merges it with what the provider shows now and emits the result as + `derived.revision_history`, on every path, so history also crosses a bundle + whose verification evidence could not be read. A missing, unverifiable, + foreign or later predecessor is an explicit gap (`REVISION_HISTORY_GAP`); + nothing older is ever carried in its place, and `verify` checks that the + source a bundle names is the bundle its manifest follows + (`HISTORY_SOURCE_MISMATCH`). Favorable evidence proves `PASS_ONLY_OBSERVED` + only when every attempt of every parent is observed, so a check-suite pass, + or a run whose earlier attempts are not all known, is `UNKNOWN_HISTORY` + (R52), and unknown history makes the Vital `UNKNOWN`: T9 puts it above every + conservative branch. On the stored corpus every active revision is + attempt-complete, so no fleet project loses its band to that rule. The first + 0.2.0 bundle of a project replays the revision records of its 0.1.9 + predecessor (lineage `devostasis.ci-history.v1`) instead of starting empty. + The band table and every 0.1.9 judgement are unchanged. Cases `HIST-01..07`, + `09..11`, `13`, `15`, `16` and `19` are vectors, `08`, `12`, `17`, `18` and + `20` store tests; `HIST-14` has no accepted migration to execute. +- **T9 is adopted** (`PV-TEST-004`, generator `PV-T9-GEN-003`, accepted by + `PV-REV-TEST-004`). With the repairs above, every obligation T9 consumes is + implemented; a finite generator of about 1,270 cells over the nine former + gap families holds each cell to the accepted table. The T9v2 cell-key + vectors are still the research process's to deliver. +- **Contradictory counts are refused as input.** Counts no population can + produce (`L + U + R != N`, a beyond-28-day count above the future-boundary + count) are `InadmissibleEvidence` before classification, as both totality + contracts require, and `evaluate` and `build` report an input error (exit + status 2) instead of a band. +- **The vector format learned two things.** `given.derive` runs the derivation + over stated inventories, so a case about how a change request becomes + linked, or how a partial target list is counted, is executed where that + happens; `expect.rejected` states a refusal (`HOR-PARTIAL-15`). Seventy-one + new vectors: `DIR-CLOSED`, `DIR-INCOMPLETE`, `HOR-PARTIAL`, `DEBT-PARTIAL`, + `HIST`. + +Upgrading: the first 0.2.0 bundle of every project carries four +`RULE_VERSION_BOUNDARY` rows. Durable history starts from the predecessor's +in-window records; after the fleet's pause since 2026-09-10 (#32) none are +left, so the first carried history is effectively empty, and a project whose +predecessor kept no observations member starts with one explained gap. A +saved 0.1.x observation set under file planning recorded a reference outside +the register as `UNKNOWN`; replayed, that change request is unresolved rather +than unlinked, and a fresh observation tells the two apart. The change-request +record gains `linkage_unresolved` and the reference state `MISSING`; the +revision record gains `history_complete`. The snapshot carries the durable +history, stored grouped by parent shape: 91 KB for the busiest project of the +fleet, whose 834 dispatched workflow runs in two weeks #40 already asks about. + +Not in this release: the fifth identity move (#19, the owner's decision), the +Phase C contracts of #34 (after B7 and the compatibility policy), the Pulse +part of #39 (a question for the research side), and the "Evidence +Observatory" candidate of #47, which awaits its review. + ## 0.1.9 (2026-09-30) Four things in one release. First, the adoption of the research judgements diff --git a/README.md b/README.md index d20eb2b..962fc21 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,10 @@ Devostasis is built against those failures: configuration or provider access. - **Anti-gaming rules are part of the contract.** A revision that failed verification keeps its failure in the history window even if a retry later - passes. A zero queue is not "good flow". Full milestone linkage is a fact, - not a compliment. + passes, and even after the provider stops showing the failed attempt: the + history is carried from one bundle to the next. Closing the target a change + delivered does not un-link the change. A zero queue is not "good flow". + Full milestone linkage is a fact, not a compliment. ## The seven Vitals @@ -153,7 +155,7 @@ comes first is the consumer's policy, and no accepted contract defines it. Requires Python 3.12 or newer. The runtime uses the standard library only. ```bash -pip install git+https://github.com/drevendev/devostasis@v0.1.9 +pip install git+https://github.com/drevendev/devostasis@v0.2.0 ``` Observe one repository (a GitHub token is read from `DEVOSTASIS_GITHUB_TOKEN`, @@ -212,7 +214,7 @@ permissions: jobs: vitals: - uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.1.9 + uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.2.0 decide: needs: vitals runs-on: ubuntu-latest diff --git a/ROADMAP.md b/ROADMAP.md index 9cfbd9d..0700369 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,7 +13,7 @@ standing obligations (interrupt anything) Phase A observe ourselves honestly closed │ ▼ -Phase B durability and coverage 4 of 7 done +Phase B durability and coverage 5 of 7 done │ ▼ Phase C reach: other providers and instruments @@ -45,7 +45,6 @@ statement; the fix belongs in this repository, not in the rule. | --- | --- | --- | | B1 vectors | research process, then this repository | `PV-TEST-001` is being produced as `PV-TEST-VECTORS-00n` units; their findings are [#20](https://github.com/drevendev/Devostasis/issues/20), [#21](https://github.com/drevendev/Devostasis/issues/21), [#22](https://github.com/drevendev/Devostasis/issues/22) and [#23](https://github.com/drevendev/Devostasis/issues/23), and two of them need vector kinds this repository has not built | | B7 first outside consumer | **the owner** | picking a repository; B5 has landed, so the consumer surface no longer moves | -| B3 durable revision history | **this repository** | `PV-HIST-002` was accepted by `PV-REV-HIST-002` on 2026-09-08 ([#34](https://github.com/drevendev/Devostasis/issues/34)); nothing blocks it but sequencing | | C1 GitLab adapter | **this repository, after B7 and the compatibility policy** | the requirements are accepted (`PV-GITLAB-003` by `PV-REV-GITLAB-003`, [#34](https://github.com/drevendev/Devostasis/issues/34)) | | C2 uncollected GitHub surfaces | this repository | each surface needs a contract decision first | | C3 Instruments | **this repository, after B7 and the compatibility policy** | the envelope, the carrier and four instruments are accepted ([#34](https://github.com/drevendev/Devostasis/issues/34)); the carrier moves the configuration and bundle contracts, which is why the policy comes first | @@ -55,10 +54,10 @@ statement; the fix belongs in this repository, not in the rule. | Compatibility policy | **this repository, then the owner** | `devostasis.contract-compatibility.v1` is accepted (`PV-COMPAT-002` by `PV-REV-COMPAT-002`, [#34](https://github.com/drevendev/Devostasis/issues/34)); adopting it is what makes the Phase C contract moves safe for an adopter | | `PV-CAL-004` predictive validity | elapsed time | needs calendar days of bundles, not more bundles (see the self-review) | | Judgements owed to us | delivered | all four, listed below; one of them is a required repair | -| Accepted judgements not yet adopted | **this repository, and the owner for one** | the Vital repairs of [#33](https://github.com/drevendev/Devostasis/issues/33) (Direction closed-target and incomplete, Debt partial, Horizon partial, T9; Integrity totality was adopted in 0.1.9), the Phase B and C contracts of [#34](https://github.com/drevendev/Devostasis/issues/34), 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 | +| 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) | -| 0.2.0 candidate | research review, then this repository | the owner asked for a large next release on 2026-09-30 and the research process produced a candidate specification (`PV-RELEASE-020-001`, not yet accepted); [#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; 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 | The four judgements the research process owed this repository about work already shipped have all been delivered: @@ -67,7 +66,7 @@ already shipped have all been delivered: | --- | --- | --- | | `PV-REV-REGISTERS-001` | whether a `Target: ` marker is auditable enough under G7, and whether a bulk-editable register is gameable | ACCEPT (J1..J6, cases REG-01..08): the literal marker satisfies G7; editability stays provenance-visible; no new rule | | `PV-REV-FLEET-001` | whether `devostasis.fleet.v1` is right to declare no cross-project ordering | ACCEPT (FLEET-01..10): `aggregate` and `cross_project_order` stay exactly null; a control plane that routes across repositories owns that policy outside Devostasis | -| `PV-REV-DIRECTION-CLOSED-TARGET-001` | calibration finding 9: closing a delivered target un-links the work that delivered it | REPAIR REQUIRED: Direction linkage must be state-neutral, an active change request linked to a resolvable declared target stays linked when the target closes; versioned Direction rule, cases DIR-CLOSED-01..09; not yet adopted, tracked in [#33](https://github.com/drevendev/Devostasis/issues/33) | +| `PV-REV-DIRECTION-CLOSED-TARGET-001` | calibration finding 9: closing a delivered target un-links the work that delivered it | REPAIR REQUIRED: Direction linkage must be state-neutral, an active change request linked to a resolvable declared target stays linked when the target closes; versioned Direction rule, cases DIR-CLOSED-01..09; adopted in 0.2.0 as `direction.bands.v2` ([#33](https://github.com/drevendev/Devostasis/issues/33)) | | `PV-SPEC-001` | the conformance review of every adoption since the specification was last reviewed, including B5 | two passes on 2026-09-07: the 0.1.8 adoption reused the ORDER identifiers (repaired before the tag, #16); the post-repair pass found the runtime conformant and the public status prose stale, which 0.1.9 reconciles in `PROVENANCE.md` and here | Delivered judgements and their adoption. Under the standing obligation above @@ -83,8 +82,9 @@ an owner decision: | `PV-REV-TEST-VECTORS-004/005/007` | [#22](https://github.com/drevendev/Devostasis/issues/22) | adopted in 0.1.9: `R3`, `R4`, `T4`, `T5`, `T7` vectors; the T5 diagnostic and the T7 upper-bound path in the evaluators; whether the GitHub adapter emits `retention_semantics` is a fleet-wide decision still open there, and since the Clutter adoption it also decides whether a capped branch head resolution can ever prove a floor on GitHub | | `PV-CLUTTER-INCOMPLETE-001`, `PV-ISSUE-026-RECONCILE-001`, `PV-REV-PR-031-003` | [#26](https://github.com/drevendev/Devostasis/issues/26) | adopted in 0.1.9: `clutter.bands.v1`, cases `CLU-INCOMPLETE-01..20` executable; an incomplete component is a confirmed burden floor, never a manufactured band, and a `PARTIAL` count proves one only with its subset proof (`CLU-PARTIAL-TRUST-01..08`) | | `PV-INT-TOTALITY-001` | [#33](https://github.com/drevendev/Devostasis/issues/33) | adopted in 0.1.9 under the same Integrity rule version, in place of the unresolved superset the review of 2026-09-30 found could omit the band it emitted ([#48](https://github.com/drevendev/Devostasis/issues/48)); `INT-TOTAL-01..06` and `08..13` executable | -| `PV-REV-DIRECTION-CLOSED-TARGET-001`, `PV-DEBT-PARTIAL-001`, `PV-HORIZON-PARTIAL-001`, `PV-DIRECTION-INCOMPLETE-001`, `PV-TEST-004` | [#33](https://github.com/drevendev/Devostasis/issues/33) | **not adopted**: accepted repairs of three Vitals and the final T9 reconciliation, none of which had an issue here until the review of 2026-09-25 read the registry; each is a versioned rule adoption with named cases | -| `PV-REV-HIST-002`, `PV-REV-GITLAB-003`, the instrument contracts, `PV-COMPAT-002`, `PV-CONFORMANCE-SURFACE-001`, `PV-RENDER-CLINICAL-001` | [#34](https://github.com/drevendev/Devostasis/issues/34) | **not adopted**: the Phase B and C contracts this roadmap called blocked by research; they are ours now, sequenced after B7 and the compatibility policy | +| `PV-REV-DIRECTION-CLOSED-TARGET-001`, `PV-DEBT-PARTIAL-001`, `PV-HORIZON-PARTIAL-001`, `PV-DIRECTION-INCOMPLETE-001`, `PV-TEST-004` | [#33](https://github.com/drevendev/Devostasis/issues/33) | adopted in 0.2.0: `direction.bands.v2`, `horizon.bands.v2`, `debt.bands.v2`, one rule version each; `DIR-CLOSED-01..09`, `DIR-INCOMPLETE-01..16`, `HOR-PARTIAL-01..16`, `DEBT-PARTIAL-01..16` executable; T9 held by its generator over the nine former gap families | +| `PV-REV-HIST-002` | [#34](https://github.com/drevendev/Devostasis/issues/34) | adopted in 0.2.0 as target B3: `integrity.bands.v1+ci-unit-004+hist-002`, HIST-01..20 executable except HIST-14, which has no accepted migration to execute | +| `PV-REV-GITLAB-003`, the instrument contracts, `PV-COMPAT-002`, `PV-CONFORMANCE-SURFACE-001`, `PV-RENDER-CLINICAL-001` | [#34](https://github.com/drevendev/Devostasis/issues/34) | **not adopted**: the Phase C contracts this roadmap called blocked by research; they are ours now, sequenced after B7 and the compatibility policy | | `PV-REV-RECEIPT-IDENTITY-003` | [#19](https://github.com/drevendev/Devostasis/issues/19) | **not adopted**: the fifth identity move, a fresh receipt, observations and manifest lineage with historical verification dispatch; it needs the owner to want it, and the contract document to implement from | One judgement is ours to ask for rather than to wait on: `ORDER-01..15` are @@ -108,12 +108,12 @@ share for Direction, and its own attention order is actionable. ### B1. Executable conformance vectors — the format is built, the vectors are owed -The specification names **63 conformance cases with no test behind them** -(T3, T6, T8, T9, R5..R53, ART-05, ART-08..ART-11, ART-15, RPT-4..RPT-6, -RPT-9). It named 70 until 0.1.9 adopted the seven exact vectors the research -process has accepted so far (`T2`, `R1`, `R2`, `R3`, `R4`, `T4`, `T5`, `T7`); -the rest are still the research process's to produce, one accepted family per -unit. +The specification names **62 conformance cases with no test behind them** +(T3, T6, T8, R5..R53, ART-05, ART-08..ART-11, ART-15, RPT-4..RPT-6, RPT-9). +It named 70 until 0.1.9 adopted the seven exact vectors the research process +has accepted so far (`T2`, `R1`, `R2`, `R3`, `R4`, `T4`, `T5`, `T7`), and 63 +until 0.2.0 held T9 to its accepted table with a generator; the rest are +still the research process's to produce, one accepted family per unit. The half that was ours shipped in 0.1.8: `devostasis.vectors.v1`, a runner, a `devostasis vectors` command and a published schema @@ -144,17 +144,6 @@ one workflow, its own `GITHUB_TOKEN`, no secret. The point is not the number of adopters but the first feedback from a consumer who did not write the contract. The only remaining blocker is the owner choosing a repository. -### B3. Durable revision history across bundles — accepted by research, ours to build - -Integrity history is reconstructed from what the provider still exposes; -parent-level surfaces cannot prove earlier failures, which is diagnosed as -`HISTORY_PROVENANCE_PARENT_LEVEL_ONLY`. Persisting `revision_history_state` -per revision across bundles closes that gap, with policy provenance and -replay-or-`INCOMPARABLE` on semantic changes. The contract is `PV-HIST-002`, -accepted by `PV-REV-HIST-002` on 2026-09-08 -([#34](https://github.com/drevendev/Devostasis/issues/34)); it changes -Integrity's history source, not the consumer surface, so it can go before -the Phase C contract moves. ### Also open from the reporting review @@ -163,6 +152,15 @@ fixture for the store. Ours, and covered by B1's format. ### Done +- **B3** (0.2.0): durable revision history across bundles, `PV-HIST-002` + (accepted by `PV-REV-HIST-002`). The union of every attempt observed per + parent per revision is carried from the immediate predecessor + (`ci.revision_history_carried` in, `derived.revision_history` out), so a + failure the provider stops showing still counts while its revision is in + the window, and a gap in the chain is explicit rather than bridged by an + older bundle. Parent-level and incomplete favorable evidence is + `UNKNOWN_HISTORY`, never a reconstructed pass (R52). It changed Integrity's + history source, not the consumer surface. - **B5** (0.1.8): the band ordering of `PV-BAND-ORDER-001`, adopted. `delta.json` is `devostasis.delta.v2`, emits `IMPROVED` and `WORSENED` where a Vital declares an order over the pair, and names the ordering it applied. @@ -279,9 +277,12 @@ trustworthy. ## What ends the loop -**0.2 is reached when** Phase A and Phase B are closed, which includes one +**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 is waiting for adoption. +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, +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, a second provider is implemented, and the calibration corpus is large enough @@ -377,15 +378,17 @@ Judged since: 8. **Per-Vital rule version boundary.** Accepted by `PV-REV-RULEBOUNDARY-001`: a Vital whose `rule_id` changed is `INCOMPARABLE` on its own while the bundle stays `COMPARABLE`. +9. **Closing a delivered target un-links the work that delivered it.** + Direction counted links to *open* targets, so completing a target removed + the linkage of the pull requests that delivered it while they were still + in the 28-day window. Measured here: closing A1 and B6 took the linked + count from 2 of 6 to 0. Repaired in `direction.bands.v2` (0.2.0, + `PV-REV-DIRECTION-CLOSED-TARGET-001`): on the same store this repository + reads 9 of 18 active change requests traced, `MIXED`, where the open-target + count said 4 of 18, `SCATTERED`. Open: -9. **Closing a delivered target un-links the work that delivered it.** - Direction counts links to *open* targets, so completing a target removes - the linkage of the pull requests that delivered it while they are still in - the 28-day window. Measured here: closing A1 and B6 took the linked count - from 2 of 6 to 0. Never closing a target would keep the band higher than - finishing the work. Awaiting `PV-REV-DIRECTION-CLOSED-TARGET-001`. 10. **Activity can declare an interval wider than its evidence.** Declared since 0.1.7 with `INTERVAL_EXCEEDS_EVIDENCE_WINDOW`; whether collection should widen instead is [issue #9](https://github.com/drevendev/Devostasis/issues/9). diff --git a/docs/deployment.md b/docs/deployment.md index e76fd4d..9c87208 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -24,7 +24,7 @@ permissions: jobs: vitals: - uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.1.9 + uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.2.0 with: debt-labels: "type:debt" # optional: issue labels that mark debt items # planning-source: file # optional: targets register instead of milestones @@ -161,7 +161,7 @@ jobs: with: python-version: "3.12" - name: Install Devostasis - run: python -m pip install --quiet "git+https://github.com/drevendev/devostasis@v0.1.9" + run: python -m pip install --quiet "git+https://github.com/drevendev/devostasis@v0.2.0" - name: Observe every configured project id: run continue-on-error: true diff --git a/examples/observations.json b/examples/observations.json index 6cda0b8..37abb75 100644 --- a/examples/observations.json +++ b/examples/observations.json @@ -46,6 +46,7 @@ "committed_at": "2026-08-22T09:00:00Z", "current_verdict": "VERIFY_PASS", "historical_contribution": "VERIFY_PASS", + "history_complete": true, "history_provenance": "ATTEMPT_LEVEL", "history_state": "PASS_ONLY_OBSERVED", "parents": [ @@ -73,6 +74,7 @@ "committed_at": "2026-08-23T09:00:00Z", "current_verdict": "VERIFY_PASS", "historical_contribution": "VERIFY_PASS", + "history_complete": true, "history_provenance": "ATTEMPT_LEVEL", "history_state": "PASS_ONLY_OBSERVED", "parents": [ @@ -100,6 +102,7 @@ "committed_at": "2026-08-24T09:00:00Z", "current_verdict": "VERIFY_PASS", "historical_contribution": "VERIFY_PASS", + "history_complete": true, "history_provenance": "ATTEMPT_LEVEL", "history_state": "PASS_ONLY_OBSERVED", "parents": [ @@ -127,6 +130,7 @@ "committed_at": "2026-08-25T09:00:00Z", "current_verdict": "VERIFY_PASS", "historical_contribution": "VERIFY_FAIL", + "history_complete": true, "history_provenance": "ATTEMPT_LEVEL", "history_state": "FAILURE_OBSERVED", "parents": [ @@ -158,6 +162,7 @@ "committed_at": "2026-08-26T09:00:00Z", "current_verdict": "VERIFY_PASS", "historical_contribution": "VERIFY_PASS", + "history_complete": true, "history_provenance": "ATTEMPT_LEVEL", "history_state": "PASS_ONLY_OBSERVED", "parents": [ @@ -185,6 +190,7 @@ "committed_at": "2026-08-27T09:00:00Z", "current_verdict": "VERIFY_PASS", "historical_contribution": "VERIFY_PASS", + "history_complete": true, "history_provenance": "ATTEMPT_LEVEL", "history_state": "PASS_ONLY_OBSERVED", "parents": [ @@ -212,6 +218,7 @@ "committed_at": "2026-08-28T09:00:00Z", "current_verdict": "VERIFY_PASS", "historical_contribution": "VERIFY_PASS", + "history_complete": true, "history_provenance": "ATTEMPT_LEVEL", "history_state": "PASS_ONLY_OBSERVED", "parents": [ @@ -239,6 +246,7 @@ "committed_at": "2026-08-29T09:00:00Z", "current_verdict": "VERIFY_PASS", "historical_contribution": "VERIFY_PASS", + "history_complete": true, "history_provenance": "ATTEMPT_LEVEL", "history_state": "PASS_ONLY_OBSERVED", "parents": [ @@ -291,10 +299,17 @@ "created_at": "2026-09-02T10:00:00Z", "draft": false, "id": 40, + "linkage_unresolved": [], "merged_at": null, "number": 40, "state": "OPEN", "target_id": "3", + "target_refs": [ + { + "state": "OPEN", + "target_id": "3" + } + ], "target_state": "OPEN", "title": "Refactor the pricing module", "updated_at": "2026-09-04T10:00:00Z", @@ -306,10 +321,12 @@ "created_at": "2026-09-03T10:00:00Z", "draft": true, "id": 41, + "linkage_unresolved": [], "merged_at": null, "number": 41, "state": "OPEN", "target_id": null, + "target_refs": [], "target_state": null, "title": "Add the export button", "updated_at": "2026-09-05T08:00:00Z", @@ -321,10 +338,17 @@ "created_at": "2026-08-13T10:00:00Z", "draft": false, "id": 31, + "linkage_unresolved": [], "merged_at": "2026-08-14T06:00:00Z", "number": 31, "state": "MERGED", "target_id": "3", + "target_refs": [ + { + "state": "OPEN", + "target_id": "3" + } + ], "target_state": "OPEN", "title": "Merged change 1", "updated_at": "2026-08-14T10:00:00Z", @@ -336,11 +360,18 @@ "created_at": "2026-08-14T10:00:00Z", "draft": false, "id": 32, + "linkage_unresolved": [], "merged_at": "2026-08-15T06:00:00Z", "number": 32, "state": "MERGED", - "target_id": null, - "target_state": null, + "target_id": "2", + "target_refs": [ + { + "state": "CLOSED", + "target_id": "2" + } + ], + "target_state": "CLOSED", "title": "Merged change 2", "updated_at": "2026-08-15T10:00:00Z", "url": "https://example.invalid/pull/32" @@ -351,10 +382,17 @@ "created_at": "2026-08-15T10:00:00Z", "draft": false, "id": 33, + "linkage_unresolved": [], "merged_at": "2026-08-16T06:00:00Z", "number": 33, "state": "MERGED", "target_id": "3", + "target_refs": [ + { + "state": "OPEN", + "target_id": "3" + } + ], "target_state": "OPEN", "title": "Merged change 3", "updated_at": "2026-08-16T10:00:00Z", @@ -366,10 +404,12 @@ "created_at": "2026-08-16T10:00:00Z", "draft": false, "id": 34, + "linkage_unresolved": [], "merged_at": "2026-08-17T06:00:00Z", "number": 34, "state": "MERGED", "target_id": null, + "target_refs": [], "target_state": null, "title": "Merged change 4", "updated_at": "2026-08-17T10:00:00Z", @@ -381,10 +421,17 @@ "created_at": "2026-08-17T10:00:00Z", "draft": false, "id": 35, + "linkage_unresolved": [], "merged_at": "2026-08-18T06:00:00Z", "number": 35, "state": "MERGED", "target_id": "3", + "target_refs": [ + { + "state": "OPEN", + "target_id": "3" + } + ], "target_state": "OPEN", "title": "Merged change 5", "updated_at": "2026-08-18T10:00:00Z", @@ -396,10 +443,12 @@ "created_at": "2026-08-18T10:00:00Z", "draft": false, "id": 36, + "linkage_unresolved": [], "merged_at": "2026-08-19T06:00:00Z", "number": 36, "state": "MERGED", "target_id": null, + "target_refs": [], "target_state": null, "title": "Merged change 6", "updated_at": "2026-08-19T10:00:00Z", @@ -411,10 +460,17 @@ "created_at": "2026-08-19T10:00:00Z", "draft": false, "id": 37, + "linkage_unresolved": [], "merged_at": "2026-08-20T06:00:00Z", "number": 37, "state": "MERGED", "target_id": "3", + "target_refs": [ + { + "state": "OPEN", + "target_id": "3" + } + ], "target_state": "OPEN", "title": "Merged change 7", "updated_at": "2026-08-20T10:00:00Z", @@ -426,10 +482,12 @@ "created_at": "2026-08-20T10:00:00Z", "draft": false, "id": 38, + "linkage_unresolved": [], "merged_at": "2026-08-21T06:00:00Z", "number": 38, "state": "MERGED", "target_id": null, + "target_refs": [], "target_state": null, "title": "Merged change 8", "updated_at": "2026-08-21T10:00:00Z", diff --git a/examples/sample-bundle/demand.json b/examples/sample-bundle/demand.json index 8b389dd..fe4701d 100644 --- a/examples/sample-bundle/demand.json +++ b/examples/sample-bundle/demand.json @@ -63,7 +63,7 @@ { "band": "MIXED", "evaluation_status": "AVAILABLE", - "gauge": 50, + "gauge": 60, "level": "MEDIUM", "reason": "BAND:MIXED", "vital_id": "direction" diff --git a/examples/sample-bundle/gauges.json b/examples/sample-bundle/gauges.json index 3eef643..9fd44e0 100644 --- a/examples/sample-bundle/gauges.json +++ b/examples/sample-bundle/gauges.json @@ -27,7 +27,7 @@ "contract": "devostasis.gauge.v1", "qualifier": "exact", "scale": "traceability share", - "value": 50, + "value": 60, "vital_id": "direction" }, { diff --git a/examples/sample-bundle/manifest.json b/examples/sample-bundle/manifest.json index 7e3f16d..c7e09f9 100644 --- a/examples/sample-bundle/manifest.json +++ b/examples/sample-bundle/manifest.json @@ -6,7 +6,7 @@ } ], "artifact_contract_version": "devostasis.bundle.v2", - "bundle_id": "45f84cc2652cb5d80e3b7edb689fa4b7973a5c5c76db2b5cfb2df06e8a2ec6c6", + "bundle_id": "422f6c269728988da2808817b5e72db3f3df97a330fb91c52f3f53e6ee76554c", "bundle_identity_contract": "PV-BUNDLE-ID-002", "canonical_member_profile": { "activity_json": "ENABLED", @@ -36,13 +36,13 @@ "config_version": "example-1", "delta_digest": "sha256:a36c8ad814fc8ae9c9e2d0b31f7764fc7a5ca959a39017922a389994029cf8f8", "demand_contract": "devostasis.demand.v2", - "demand_digest": "sha256:615dbc00bee70964a95bded568a13b56d8a77d18cf5db1f6e6a54ddba4b6acca", + "demand_digest": "sha256:38f9170d967c28516dcaf4ca34d77815c0b5589701c076fa1ee176a4f64f0d94", "effective_config_contract": "PV-EFFECTIVE-CONFIG-001", "effective_config_digest": "sha256:cc472c9ad93f6721ecc9a3fc14f3067ccc85af33c2acceb4489982a44eb370ac", "gauge_contract": "devostasis.gauge.v1", - "gauges_digest": "sha256:9625ff34cc358d9343d865f6900a20c357b400f47be23a414775c21261bbc628", + "gauges_digest": "sha256:f396534cc1b29a280d8680d2ce89826dfc9ef99f7cd84262cb6e80009dbad2be", "observation_contract_version": "RAW-OBS-V0", - "observations_digest": "sha256:8be202ed47fbf0fc95288df179037b061e02192f6a48f6507b6cab5eaad0ce98", + "observations_digest": "sha256:78603834c2e4cb90798cc144488c5b9e8d99407908269572a622ed13f1e58b33", "observed_at": "2026-09-05T12:00:00Z", "policy_version": "devostasis.policy.v1", "previous_bundle_id": null, @@ -54,19 +54,19 @@ "provider": "github" }, "renderer_version": "devostasis.render.v4", - "snapshot_digest": "sha256:f5b315d95227724289b459582cc4352e8a64ca9ee7a5a90457c024377e7e3e23", + "snapshot_digest": "sha256:d0274768919e75f1c6f76b6720e7612282229f7af11f050d76cc8a29bf4b85b9", "source_receipts_digest": "sha256:fda4a9bc9f4f74739ecd59c85eed55a83e95a7085ae22fa7d82d9c98f36ecde3", "vitals_contract_version": "PV-VITALS-V1-002" }, "members": { "activity.json": "sha256:d80fa63f29071d59425dad060211db5fbf6d165248c23b18169b06c37a1a0595", "delta.json": "sha256:a36c8ad814fc8ae9c9e2d0b31f7764fc7a5ca959a39017922a389994029cf8f8", - "demand.json": "sha256:615dbc00bee70964a95bded568a13b56d8a77d18cf5db1f6e6a54ddba4b6acca", + "demand.json": "sha256:38f9170d967c28516dcaf4ca34d77815c0b5589701c076fa1ee176a4f64f0d94", "effective-config.json": "sha256:cc472c9ad93f6721ecc9a3fc14f3067ccc85af33c2acceb4489982a44eb370ac", - "gauges.json": "sha256:9625ff34cc358d9343d865f6900a20c357b400f47be23a414775c21261bbc628", - "observations.json": "sha256:8be202ed47fbf0fc95288df179037b061e02192f6a48f6507b6cab5eaad0ce98", - "report.md": "sha256:560dd5d90ac4188e028870a994c5bcd0a534d83bc5fb22b889af5448fa027ed2", - "snapshot.json": "sha256:f5b315d95227724289b459582cc4352e8a64ca9ee7a5a90457c024377e7e3e23" + "gauges.json": "sha256:f396534cc1b29a280d8680d2ce89826dfc9ef99f7cd84262cb6e80009dbad2be", + "observations.json": "sha256:78603834c2e4cb90798cc144488c5b9e8d99407908269572a622ed13f1e58b33", + "report.md": "sha256:4b7c14593a029b47956d7343c839d221b9f4146a960c1a9eba3d6894cf9f2598", + "snapshot.json": "sha256:d0274768919e75f1c6f76b6720e7612282229f7af11f050d76cc8a29bf4b85b9" }, "observation_contract_version": "RAW-OBS-V0", "observed_at": "2026-09-05T12:00:00Z", @@ -167,7 +167,7 @@ "example": true, "tool": { "name": "devostasis", - "version": "0.1.9" + "version": "0.2.0" } }, "schema": "devostasis.manifest.v1", diff --git a/examples/sample-bundle/observations.json b/examples/sample-bundle/observations.json index 7827d02..415e73e 100644 --- a/examples/sample-bundle/observations.json +++ b/examples/sample-bundle/observations.json @@ -19,6 +19,22 @@ "value": true, "value_type": "boolean" }, + { + "adapter_version": "devostasis.ci-history.v2", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": null, + "evidence_ref": null, + "freshness": "FRESH", + "notes": null, + "observation_id": "ci.revision_history_carried", + "provider": "history", + "reason_code": "NO_PREVIOUS_BUNDLE", + "schema_version": "RAW-OBS-V0", + "source_ref": "store:none", + "status": "UNAVAILABLE", + "value": null, + "value_type": "record" + }, { "adapter_version": "devostasis.github.v1", "collected_at": "2026-09-05T12:00:00Z", @@ -46,6 +62,7 @@ "committed_at": "2026-08-22T09:00:00Z", "current_verdict": "VERIFY_PASS", "historical_contribution": "VERIFY_PASS", + "history_complete": true, "history_provenance": "ATTEMPT_LEVEL", "history_state": "PASS_ONLY_OBSERVED", "parents": [ @@ -73,6 +90,7 @@ "committed_at": "2026-08-23T09:00:00Z", "current_verdict": "VERIFY_PASS", "historical_contribution": "VERIFY_PASS", + "history_complete": true, "history_provenance": "ATTEMPT_LEVEL", "history_state": "PASS_ONLY_OBSERVED", "parents": [ @@ -100,6 +118,7 @@ "committed_at": "2026-08-24T09:00:00Z", "current_verdict": "VERIFY_PASS", "historical_contribution": "VERIFY_PASS", + "history_complete": true, "history_provenance": "ATTEMPT_LEVEL", "history_state": "PASS_ONLY_OBSERVED", "parents": [ @@ -127,6 +146,7 @@ "committed_at": "2026-08-25T09:00:00Z", "current_verdict": "VERIFY_PASS", "historical_contribution": "VERIFY_FAIL", + "history_complete": true, "history_provenance": "ATTEMPT_LEVEL", "history_state": "FAILURE_OBSERVED", "parents": [ @@ -158,6 +178,7 @@ "committed_at": "2026-08-26T09:00:00Z", "current_verdict": "VERIFY_PASS", "historical_contribution": "VERIFY_PASS", + "history_complete": true, "history_provenance": "ATTEMPT_LEVEL", "history_state": "PASS_ONLY_OBSERVED", "parents": [ @@ -185,6 +206,7 @@ "committed_at": "2026-08-27T09:00:00Z", "current_verdict": "VERIFY_PASS", "historical_contribution": "VERIFY_PASS", + "history_complete": true, "history_provenance": "ATTEMPT_LEVEL", "history_state": "PASS_ONLY_OBSERVED", "parents": [ @@ -212,6 +234,7 @@ "committed_at": "2026-08-28T09:00:00Z", "current_verdict": "VERIFY_PASS", "historical_contribution": "VERIFY_PASS", + "history_complete": true, "history_provenance": "ATTEMPT_LEVEL", "history_state": "PASS_ONLY_OBSERVED", "parents": [ @@ -239,6 +262,7 @@ "committed_at": "2026-08-29T09:00:00Z", "current_verdict": "VERIFY_PASS", "historical_contribution": "VERIFY_PASS", + "history_complete": true, "history_provenance": "ATTEMPT_LEVEL", "history_state": "PASS_ONLY_OBSERVED", "parents": [ @@ -413,10 +437,17 @@ "created_at": "2026-09-02T10:00:00Z", "draft": false, "id": 40, + "linkage_unresolved": [], "merged_at": null, "number": 40, "state": "OPEN", "target_id": "3", + "target_refs": [ + { + "state": "OPEN", + "target_id": "3" + } + ], "target_state": "OPEN", "title": "Refactor the pricing module", "updated_at": "2026-09-04T10:00:00Z", @@ -428,10 +459,12 @@ "created_at": "2026-09-03T10:00:00Z", "draft": true, "id": 41, + "linkage_unresolved": [], "merged_at": null, "number": 41, "state": "OPEN", "target_id": null, + "target_refs": [], "target_state": null, "title": "Add the export button", "updated_at": "2026-09-05T08:00:00Z", @@ -443,10 +476,17 @@ "created_at": "2026-08-13T10:00:00Z", "draft": false, "id": 31, + "linkage_unresolved": [], "merged_at": "2026-08-14T06:00:00Z", "number": 31, "state": "MERGED", "target_id": "3", + "target_refs": [ + { + "state": "OPEN", + "target_id": "3" + } + ], "target_state": "OPEN", "title": "Merged change 1", "updated_at": "2026-08-14T10:00:00Z", @@ -458,11 +498,18 @@ "created_at": "2026-08-14T10:00:00Z", "draft": false, "id": 32, + "linkage_unresolved": [], "merged_at": "2026-08-15T06:00:00Z", "number": 32, "state": "MERGED", - "target_id": null, - "target_state": null, + "target_id": "2", + "target_refs": [ + { + "state": "CLOSED", + "target_id": "2" + } + ], + "target_state": "CLOSED", "title": "Merged change 2", "updated_at": "2026-08-15T10:00:00Z", "url": "https://example.invalid/pull/32" @@ -473,10 +520,17 @@ "created_at": "2026-08-15T10:00:00Z", "draft": false, "id": 33, + "linkage_unresolved": [], "merged_at": "2026-08-16T06:00:00Z", "number": 33, "state": "MERGED", "target_id": "3", + "target_refs": [ + { + "state": "OPEN", + "target_id": "3" + } + ], "target_state": "OPEN", "title": "Merged change 3", "updated_at": "2026-08-16T10:00:00Z", @@ -488,10 +542,12 @@ "created_at": "2026-08-16T10:00:00Z", "draft": false, "id": 34, + "linkage_unresolved": [], "merged_at": "2026-08-17T06:00:00Z", "number": 34, "state": "MERGED", "target_id": null, + "target_refs": [], "target_state": null, "title": "Merged change 4", "updated_at": "2026-08-17T10:00:00Z", @@ -503,10 +559,17 @@ "created_at": "2026-08-17T10:00:00Z", "draft": false, "id": 35, + "linkage_unresolved": [], "merged_at": "2026-08-18T06:00:00Z", "number": 35, "state": "MERGED", "target_id": "3", + "target_refs": [ + { + "state": "OPEN", + "target_id": "3" + } + ], "target_state": "OPEN", "title": "Merged change 5", "updated_at": "2026-08-18T10:00:00Z", @@ -518,10 +581,12 @@ "created_at": "2026-08-18T10:00:00Z", "draft": false, "id": 36, + "linkage_unresolved": [], "merged_at": "2026-08-19T06:00:00Z", "number": 36, "state": "MERGED", "target_id": null, + "target_refs": [], "target_state": null, "title": "Merged change 6", "updated_at": "2026-08-19T10:00:00Z", @@ -533,10 +598,17 @@ "created_at": "2026-08-19T10:00:00Z", "draft": false, "id": 37, + "linkage_unresolved": [], "merged_at": "2026-08-20T06:00:00Z", "number": 37, "state": "MERGED", "target_id": "3", + "target_refs": [ + { + "state": "OPEN", + "target_id": "3" + } + ], "target_state": "OPEN", "title": "Merged change 7", "updated_at": "2026-08-20T10:00:00Z", @@ -548,10 +620,12 @@ "created_at": "2026-08-20T10:00:00Z", "draft": false, "id": 38, + "linkage_unresolved": [], "merged_at": "2026-08-21T06:00:00Z", "number": 38, "state": "MERGED", "target_id": null, + "target_refs": [], "target_state": null, "title": "Merged change 8", "updated_at": "2026-08-21T10:00:00Z", @@ -1422,6 +1496,33 @@ }, "freshness": "FRESH", "notes": null, + "observation_id": "planning.linkage.active_change_requests_linked_count_28d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.change_requests.inventory", + "status": "AVAILABLE", + "value": 6, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.change_requests.inventory" + ] + }, + "freshness": "FRESH", + "notes": "active change requests linked to a target that is open now; presentation only since direction.bands.v2, never a classifier input", "observation_id": "planning.linkage.active_change_requests_linked_to_open_target_count_28d", "provider": "github", "reason_code": null, @@ -1431,6 +1532,60 @@ "value": 5, "value_type": "count" }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.change_requests.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "planning.linkage.active_change_requests_unlinked_count_28d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.change_requests.inventory", + "status": "AVAILABLE", + "value": 4, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.change_requests.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "planning.linkage.active_change_requests_unresolved_count_28d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.change_requests.inventory", + "status": "AVAILABLE", + "value": 0, + "value_type": "count" + }, { "adapter_version": "devostasis.github.v1", "collected_at": "2026-09-05T12:00:00Z", @@ -1456,10 +1611,38 @@ "source_ref": "derived:forge.change_requests.inventory", "status": "AVAILABLE", "value": { + "2": 1, "3": 5 }, "value_type": "record" }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.change_requests.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "planning.linkage.missing_target_reference_count_28d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.change_requests.inventory", + "status": "AVAILABLE", + "value": 0, + "value_type": "count" + }, { "adapter_version": "devostasis.github.v1", "collected_at": "2026-09-05T12:00:00Z", @@ -1486,6 +1669,33 @@ "status": "AVAILABLE", "value": 0, "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.change_requests.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "planning.linkage.unresolved_change_requests_28d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.change_requests.inventory", + "status": "AVAILABLE", + "value": {}, + "value_type": "record" } ], "observed_at": "2026-09-05T12:00:00Z", diff --git a/examples/sample-bundle/report.md b/examples/sample-bundle/report.md index 5a334f6..284a3fd 100644 --- a/examples/sample-bundle/report.md +++ b/examples/sample-bundle/report.md @@ -2,13 +2,13 @@ - Observed at: 2026-09-05T12:00:00Z - Comparison: BASELINE -- Bundle: `45f84cc2652cb5d80e3b7edb689fa4b7973a5c5c76db2b5cfb2df06e8a2ec6c6` +- Bundle: `422f6c269728988da2808817b5e72db3f3df97a330fb91c52f3f53e6ee76554c` - Contracts: vitals PV-VITALS-V1-002, observations RAW-OBS-V0, policy devostasis.policy.v1 ```text Horizon ████████░░ 84 EXTENDED Clutter █░░░░░░░░░ 10 LIGHT -Direction █████░░░░░ 50 MIXED +Direction ██████░░░░ 60 MIXED Flow █░░░░░░░░░ 10 MOVING Integrity ██████░░░░ 62 FLAKY Debt █░░░░░░░░░ 11 PRESENT @@ -24,7 +24,7 @@ Demand levels come from mapping `devostasis-default-1` (devostasis.demand.v2); i | Order | Vital | Level | Band | Gauge | | --- | --- | --- | --- | --- | | 1 | Integrity | HIGH | FLAKY | 62 | -| 2 | Direction | MEDIUM | MIXED | 50 | +| 2 | Direction | MEDIUM | MIXED | 60 | | 3 | Debt | MEDIUM | PRESENT | 11 | | 4 | Clutter | LOW | LIGHT | 10 | | 5 | Flow | LOW | MOVING | 10 | @@ -37,7 +37,7 @@ Demand levels come from mapping `devostasis-default-1` (devostasis.demand.v2); i | --- | --- | --- | --- | --- | --- | | Horizon | 84 | EXTENDED | AVAILABLE | EXACT | 1 open planning targets, 1 with a future boundary, 1 reaching beyond 28 days. | | Clutter | 10 | LIGHT | AVAILABLE | EXACT | 1 stale work items out of 5 tracked open items; 1 stale non-default branches. | -| Direction | 50 | MIXED | AVAILABLE | EXACT | 5 of 10 active change requests are explicitly linked to an open planning target. | +| Direction | 60 | MIXED | AVAILABLE | EXACT | 6 of 10 active change requests are explicitly linked to a declared target, open or closed. | | Flow | 10 | MOVING | AVAILABLE | EXACT | 2 open change requests; 8 merged in 28 days; oldest open for 3 days; median time to merge 20h 0m. | | Integrity | 62 | FLAKY | AVAILABLE | EXACT | 1 of 8 decisive revisions failed verification in 14 days; latest decisive verdict is VERIFY_PASS. | | Debt | 11 | PRESENT | AVAILABLE | EXACT | 1 open registered debt items under mapping version example-1. | @@ -47,7 +47,7 @@ Demand levels come from mapping `devostasis-default-1` (devostasis.demand.v2); i _Is future work explicitly declared, and does any declaration reach beyond 28 days?_ -- Evaluation: AVAILABLE; rule `horizon.bands.v1` +- Evaluation: AVAILABLE; rule `horizon.bands.v2` - Gauge (declared future work): ████████░░ 84 - Shares signals with: HORIZON_DIRECTION_PLANNING @@ -78,19 +78,20 @@ _How much unresolved stale residue is observable?_ _Is active change work explicitly traceable to declared targets?_ -- Evaluation: AVAILABLE; rule `direction.bands.v1.1` -- Gauge (traceability share): █████░░░░░ 50 +- Evaluation: AVAILABLE; rule `direction.bands.v2` +- Gauge (traceability share): ██████░░░░ 60 - Shares signals with: HORIZON_DIRECTION_PLANNING, DIRECTION_PULSE_ACTIVITY | Metric | Value | | --- | --- | | active_change_count_28d | 10 | | capability | SUPPORTED | -| linked_active_change_count_28d | 5 | -| unlinked_active_change_count_28d | 5 | - -Diagnostics: -- `ALL_LINKS_TO_SINGLE_TARGET` +| linkage_rule | PV-REV-DIRECTION-CLOSED-TARGET-001 | +| linked_active_change_count_28d | 6 | +| linked_to_open_target_count_28d | 5 | +| missing_target_reference_count_28d | 0 | +| unlinked_active_change_count_28d | 4 | +| unresolved_active_change_count_28d | 0 | ### Flow: MOVING @@ -112,7 +113,7 @@ _What is the state and friction of the current change-request queue?_ _What does automated verification say about recent immutable revisions?_ -- Evaluation: AVAILABLE; rule `integrity.bands.v1+ci-unit-004` +- Evaluation: AVAILABLE; rule `integrity.bands.v1+ci-unit-004+hist-002` - Gauge (verification stability): ██████░░░░ 62 - Shares signals with: INTEGRITY_ONLY @@ -121,15 +122,18 @@ _What does automated verification say about recent immutable revisions?_ | decisive_count_14d | 8 | | failed_count_14d | 1 | | failure_ratio_14d | 0.13 (1/8) | +| history_rule | PV-HIST-002 | | revisions_in_window | 8 | +| revisions_with_history | 8 | | revisions_with_verification | 8 | | sample_strength | ESTABLISHED | +| unknown_history_count_14d | 0 | ### Debt: PRESENT _How much explicitly registered maintenance obligation is unresolved?_ -- Evaluation: AVAILABLE; rule `debt.bands.v1.1` +- Evaluation: AVAILABLE; rule `debt.bands.v2` - Gauge (registered debt): █░░░░░░░░░ 11 - Shares signals with: DEBT_CLUTTER_MAINTENANCE @@ -216,5 +220,5 @@ Releases: - Artifact contract: devostasis.bundle.v2; bundle identity: PV-BUNDLE-ID-002; renderer: devostasis.render.v4; gauges: devostasis.gauge.v1; demand: devostasis.demand.v2 - Effective config digest: `sha256:cc472c9ad93f6721ecc9a3fc14f3067ccc85af33c2acceb4489982a44eb370ac` (config version `example-1`) - Adapters: github devostasis.github.v1 -- Observations digest: `sha256:8be202ed47fbf0fc95288df179037b061e02192f6a48f6507b6cab5eaad0ce98` +- Observations digest: `sha256:78603834c2e4cb90798cc144488c5b9e8d99407908269572a622ed13f1e58b33` - Generated deterministically from the machine bundle without any language model. diff --git a/examples/sample-bundle/snapshot.json b/examples/sample-bundle/snapshot.json index 3e5fbae..3bc19f3 100644 --- a/examples/sample-bundle/snapshot.json +++ b/examples/sample-bundle/snapshot.json @@ -1,6 +1,6 @@ { "observation_contract_version": "RAW-OBS-V0", - "observations_digest": "sha256:8be202ed47fbf0fc95288df179037b061e02192f6a48f6507b6cab5eaad0ce98", + "observations_digest": "sha256:78603834c2e4cb90798cc144488c5b9e8d99407908269572a622ed13f1e58b33", "observed_at": "2026-09-05T12:00:00Z", "policy_version": "devostasis.policy.v1", "schema": "devostasis.snapshot.v1", @@ -60,7 +60,7 @@ ], "policy_version": "devostasis.policy.v1", "possible_bands": null, - "rule_id": "horizon.bands.v1", + "rule_id": "horizon.bands.v2", "shared_signal_groups": [ "PLANNING_TARGETS" ], @@ -155,17 +155,20 @@ "derived": { "active_change_count_28d": 10, "capability": "SUPPORTED", - "linked_active_change_count_28d": 5, + "linkage_rule": "PV-REV-DIRECTION-CLOSED-TARGET-001", + "linked_active_change_count_28d": 6, + "linked_to_open_target_count_28d": 5, "links_per_target": { + "2": 1, "3": 5 }, - "unlinked_active_change_count_28d": 5 + "missing_target_reference_count_28d": 0, + "unlinked_active_change_count_28d": 4, + "unresolved_active_change_count_28d": 0 }, - "diagnostics": [ - "ALL_LINKS_TO_SINGLE_TARGET" - ], + "diagnostics": [], "evaluation_status": "AVAILABLE", - "explanation": "5 of 10 active change requests are explicitly linked to an open planning target.", + "explanation": "6 of 10 active change requests are explicitly linked to a declared target, open or closed.", "inputs": [ { "freshness": "FRESH", @@ -174,7 +177,17 @@ }, { "freshness": "FRESH", - "observation_id": "planning.linkage.active_change_requests_linked_to_open_target_count_28d", + "observation_id": "planning.linkage.active_change_requests_linked_count_28d", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "planning.linkage.active_change_requests_unlinked_count_28d", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "planning.linkage.active_change_requests_unresolved_count_28d", "status": "AVAILABLE" }, { @@ -186,11 +199,21 @@ "freshness": "FRESH", "observation_id": "planning.linkage.links_per_target_28d", "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "planning.linkage.missing_target_reference_count_28d", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "planning.linkage.unresolved_change_requests_28d", + "status": "AVAILABLE" } ], "policy_version": "devostasis.policy.v1", "possible_bands": null, - "rule_id": "direction.bands.v1.1", + "rule_id": "direction.bands.v2", "shared_signal_groups": [ "PLANNING_TARGETS", "CHANGE_REQUEST_ACTIVITY" @@ -274,9 +297,201 @@ "history_provenance": { "ATTEMPT_LEVEL": 8 }, + "history_rule": "PV-HIST-002", + "revision_history": { + "lineage": "devostasis.ci-history.v2", + "records": [ + { + "committed_at": "2026-08-22T09:00:00Z", + "historical_contribution": "VERIFY_PASS", + "history_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "parent_groups": [ + { + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "parent_ids": [ + "github_actions:workflow_run:900" + ] + } + ], + "revision": "000000000000000000000000000000000000000c" + }, + { + "committed_at": "2026-08-23T09:00:00Z", + "historical_contribution": "VERIFY_PASS", + "history_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "parent_groups": [ + { + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "parent_ids": [ + "github_actions:workflow_run:901" + ] + } + ], + "revision": "000000000000000000000000000000000000000d" + }, + { + "committed_at": "2026-08-24T09:00:00Z", + "historical_contribution": "VERIFY_PASS", + "history_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "parent_groups": [ + { + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "parent_ids": [ + "github_actions:workflow_run:902" + ] + } + ], + "revision": "000000000000000000000000000000000000000e" + }, + { + "committed_at": "2026-08-25T09:00:00Z", + "historical_contribution": "VERIFY_FAIL", + "history_complete": true, + "history_state": "FAILURE_OBSERVED", + "parent_groups": [ + { + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_FAIL" + }, + { + "attempt": 2, + "state": "VERIFY_PASS" + } + ], + "kind": "github_actions_workflow_run", + "latest_attempt": 2, + "parent_ids": [ + "github_actions:workflow_run:903" + ] + } + ], + "revision": "000000000000000000000000000000000000000f" + }, + { + "committed_at": "2026-08-26T09:00:00Z", + "historical_contribution": "VERIFY_PASS", + "history_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "parent_groups": [ + { + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "parent_ids": [ + "github_actions:workflow_run:904" + ] + } + ], + "revision": "0000000000000000000000000000000000000010" + }, + { + "committed_at": "2026-08-27T09:00:00Z", + "historical_contribution": "VERIFY_PASS", + "history_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "parent_groups": [ + { + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "parent_ids": [ + "github_actions:workflow_run:905" + ] + } + ], + "revision": "0000000000000000000000000000000000000011" + }, + { + "committed_at": "2026-08-28T09:00:00Z", + "historical_contribution": "VERIFY_PASS", + "history_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "parent_groups": [ + { + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "parent_ids": [ + "github_actions:workflow_run:906" + ] + } + ], + "revision": "0000000000000000000000000000000000000012" + }, + { + "committed_at": "2026-08-29T09:00:00Z", + "historical_contribution": "VERIFY_PASS", + "history_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "parent_groups": [ + { + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "parent_ids": [ + "github_actions:workflow_run:907" + ] + } + ], + "revision": "0000000000000000000000000000000000000013" + } + ], + "source": { + "reason": "NO_PREVIOUS_BUNDLE", + "status": "BASELINE" + } + }, "revisions_in_window": 8, + "revisions_with_history": 8, "revisions_with_verification": 8, - "sample_strength": "ESTABLISHED" + "sample_strength": "ESTABLISHED", + "unknown_history_count_14d": 0 }, "diagnostics": [], "evaluation_status": "AVAILABLE", @@ -291,11 +506,16 @@ "freshness": "FRESH", "observation_id": "ci.revision_verdicts_14d", "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "ci.revision_history_carried", + "status": "UNAVAILABLE" } ], "policy_version": "devostasis.policy.v1", "possible_bands": null, - "rule_id": "integrity.bands.v1+ci-unit-004", + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002", "shared_signal_groups": [ "CI_VERIFICATION" ], @@ -354,7 +574,7 @@ ], "policy_version": "devostasis.policy.v1", "possible_bands": null, - "rule_id": "debt.bands.v1.1", + "rule_id": "debt.bands.v2", "shared_signal_groups": [ "EXPLICIT_DEBT_REGISTER", "FORGE_INVENTORY" diff --git a/pyproject.toml b/pyproject.toml index 17afd52..dee14f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "devostasis" -version = "0.1.9" +version = "0.2.0" description = "Deterministic, model-free vital signs for software repositories." readme = "README.md" license = "MIT" diff --git a/scripts/make_examples.py b/scripts/make_examples.py index b7fa6d6..e565d6d 100644 --- a/scripts/make_examples.py +++ b/scripts/make_examples.py @@ -48,15 +48,26 @@ def obs(oid: str, value, value_type: str, coverage=None) -> Observation: ) +def _example_link(i: int) -> dict: + """Odd changes link to the open release 1.2; change 32 delivered release 1.1, closed since, and stays linked.""" + if i % 2: + target, state = "3", "OPEN" + elif i == 2: + target, state = "2", "CLOSED" + else: + return {"target_id": None, "target_state": None, "target_refs": [], "linkage_unresolved": []} + return {"target_id": target, "target_state": state, "target_refs": [{"target_id": target, "state": state}], "linkage_unresolved": []} + + def build_observation_set() -> ObservationSet: commits = [ {"sha": f"{i:040x}", "committed_at": f"2026-08-{10 + i:02d}T09:00:00Z", "title": f"feat: change number {i}"} for i in range(1, 20) ] change_requests = [ - {"number": 40, "id": 40, "title": "Refactor the pricing module", "state": "OPEN", "draft": False, "created_at": "2026-09-02T10:00:00Z", "updated_at": "2026-09-04T10:00:00Z", "merged_at": None, "closed_at": None, "target_id": "3", "target_state": "OPEN", "author": "dev", "url": "https://example.invalid/pull/40"}, - {"number": 41, "id": 41, "title": "Add the export button", "state": "OPEN", "draft": True, "created_at": "2026-09-03T10:00:00Z", "updated_at": "2026-09-05T08:00:00Z", "merged_at": None, "closed_at": None, "target_id": None, "target_state": None, "author": "dev", "url": "https://example.invalid/pull/41"}, + {"number": 40, "id": 40, "title": "Refactor the pricing module", "state": "OPEN", "draft": False, "created_at": "2026-09-02T10:00:00Z", "updated_at": "2026-09-04T10:00:00Z", "merged_at": None, "closed_at": None, "target_id": "3", "target_state": "OPEN", "target_refs": [{"target_id": "3", "state": "OPEN"}], "linkage_unresolved": [], "author": "dev", "url": "https://example.invalid/pull/40"}, + {"number": 41, "id": 41, "title": "Add the export button", "state": "OPEN", "draft": True, "created_at": "2026-09-03T10:00:00Z", "updated_at": "2026-09-05T08:00:00Z", "merged_at": None, "closed_at": None, "target_id": None, "target_state": None, "target_refs": [], "linkage_unresolved": [], "author": "dev", "url": "https://example.invalid/pull/41"}, ] + [ - {"number": 30 + i, "id": 30 + i, "title": f"Merged change {i}", "state": "MERGED", "draft": False, "created_at": f"2026-08-{12 + i:02d}T10:00:00Z", "updated_at": f"2026-08-{13 + i:02d}T10:00:00Z", "merged_at": f"2026-08-{13 + i:02d}T06:00:00Z", "closed_at": f"2026-08-{13 + i:02d}T06:00:00Z", "target_id": "3" if i % 2 else None, "target_state": "OPEN" if i % 2 else None, "author": "dev", "url": f"https://example.invalid/pull/{30 + i}"} + {"number": 30 + i, "id": 30 + i, "title": f"Merged change {i}", "state": "MERGED", "draft": False, "created_at": f"2026-08-{12 + i:02d}T10:00:00Z", "updated_at": f"2026-08-{13 + i:02d}T10:00:00Z", "merged_at": f"2026-08-{13 + i:02d}T06:00:00Z", "closed_at": f"2026-08-{13 + i:02d}T06:00:00Z", **_example_link(i), "author": "dev", "url": f"https://example.invalid/pull/{30 + i}"} for i in range(1, 9) ] issues = [ @@ -104,11 +115,12 @@ def build_observation_set() -> ObservationSet: "history_state": history, "historical_contribution": "VERIFY_FAIL" if history == "FAILURE_OBSERVED" else "VERIFY_PASS", "history_provenance": "ATTEMPT_LEVEL", + "history_complete": True, } ) window_revisions = [c for c in commits if c["committed_at"] >= "2026-08-22"] by_sha = {r["revision"]: r for r in revisions} - series = [by_sha.get(c["sha"], {"revision": c["sha"], "committed_at": c["committed_at"], "parents": [], "current_verdict": None, "history_state": "NO_DECISIVE_OBSERVED", "historical_contribution": None, "history_provenance": None}) for c in window_revisions] + series = [by_sha.get(c["sha"], {"revision": c["sha"], "committed_at": c["committed_at"], "parents": [], "current_verdict": None, "history_state": "NO_DECISIVE_OBSERVED", "historical_contribution": None, "history_provenance": None, "history_complete": True}) for c in window_revisions] result = ObservationSet(subject=SUBJECT, observed_at=OBSERVED_AT) result.add(obs(normalize.INV_REPO, {"id": 123456, "full_name": "acme/widget", "default_branch": "master", "visibility": "public", "has_issues": True, "archived": False, "pushed_at": "2026-09-05T08:00:00Z", "html_url": "https://example.invalid/acme/widget"}, "record")) diff --git a/src/devostasis/__init__.py b/src/devostasis/__init__.py index bc151f8..92d65bc 100644 --- a/src/devostasis/__init__.py +++ b/src/devostasis/__init__.py @@ -1,3 +1,3 @@ """Devostasis: deterministic, model-free vital signs for software repositories.""" -__version__ = "0.1.9" +__version__ = "0.2.0" diff --git a/src/devostasis/contracts.py b/src/devostasis/contracts.py index 08a161e..e1cd2c1 100644 --- a/src/devostasis/contracts.py +++ b/src/devostasis/contracts.py @@ -10,9 +10,13 @@ OBSERVATION_CONTRACT_VERSION = "RAW-OBS-V0" # Seven-Vital taxonomy: accepted PV-VITALS-V1-002 (units PV-VIT-010/012). -# Per-Vital rule versions (``rule_id`` in the snapshot) carry the calibration -# repairs adopted on top of it: flow.bands.v1 (PV-FLOW-EMPTY-QUEUE-001, -# PV-FLOW-MERGE-LATENCY-001) and pulse.bands.v1 (PV-PULSE-REQUIRED-LOWER-BOUND-001). +# Per-Vital rule versions (``rule_id`` in the snapshot) carry the repairs +# adopted on top of it: flow.bands.v1 (PV-FLOW-EMPTY-QUEUE-001, +# PV-FLOW-MERGE-LATENCY-001), pulse.bands.v1 (PV-PULSE-REQUIRED-LOWER-BOUND-001), +# clutter.bands.v1 (PV-CLUTTER-INCOMPLETE-001), and since 0.2.0 +# direction.bands.v2 (PV-REV-DIRECTION-CLOSED-TARGET-001, PV-DIRECTION-INCOMPLETE-001), +# horizon.bands.v2 (PV-HORIZON-PARTIAL-001), debt.bands.v2 (PV-DEBT-PARTIAL-001) +# and integrity.bands.v1+ci-unit-004+hist-002 (PV-HIST-002); vitals.md lists them. VITALS_CONTRACT_VERSION = "PV-VITALS-V1-002" # Integrity revision/verification semantics: accepted PV-CI-UNIT-004 (PV-VIT-011). From 26e5f6f7e7125d48e1d609a57fc3e2a1a622fddb Mon Sep 17 00:00:00 2001 From: Dreven Date: Thu, 1 Oct 2026 01:25:21 +0300 Subject: [PATCH 09/33] Refuse an Integrity record that names no revision instead of crashing on it Durable history is keyed by immutable revision identity, and the new union code read the revision of every record directly, so a hand-written record without one ended the evaluation with a KeyError. A record that names no revision can be neither counted nor carried: it is now REVISION_RECORD_INCONSISTENT, the same defect as a contribution that contradicts its own history, and the Vital is UNKNOWN. Co-Authored-By: Claude Opus 5.5 --- docs/spec/conformance.md | 2 +- src/devostasis/vitals/integrity.py | 8 +++++--- tests/conformance/test_integrity.py | 11 +++++++++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/spec/conformance.md b/docs/spec/conformance.md index 1310bc4..cd648c2 100644 --- a/docs/spec/conformance.md +++ b/docs/spec/conformance.md @@ -58,7 +58,7 @@ and an unsupported conclusion reach the Vital as `UNKNOWN`. | sample strength | one to three decisive revisions are SPARSE with `CI_SPARSE_SAMPLE`; four are ESTABLISHED | `test_sparse_samples_declare_their_strength_and_established_ones_do_not_carry_the_diagnostic` | | unresolved newest revision | over every history, a newest revision still being verified yields FAILING exactly where the established FAILING predicate already holds and no band elsewhere; the superset this rule version first carried, which could omit the band it emitted, is gone (#12 finding 3, PV-INTEGRITY-TOTALITY-001) | `test_an_unresolved_newest_revision_never_emits_a_band_its_history_does_not_already_prove`, `test_current_unresolved_is_unknown_unless_failing_is_already_established`, `test_a_newest_revision_with_one_workflow_passed_and_one_running_is_already_counted_and_still_unresolved` | | verdict vocabulary | a current verdict outside the canonical vocabulary is read as UNKNOWN, never as a non-decisive state an older pass speaks for | `test_a_verdict_outside_the_vocabulary_fails_closed_instead_of_falling_back_to_an_older_pass` | -| record consistency | a record whose contribution contradicts its own history state is a defect, not a pass | `test_a_record_whose_contribution_contradicts_its_history_is_a_defect_not_a_pass` | +| record consistency | a record whose contribution contradicts its own history state, or that names no revision, is a defect, not a pass | `test_a_record_whose_contribution_contradicts_its_history_is_a_defect_not_a_pass`, `test_a_record_without_its_revision_identity_is_a_defect_not_a_crash` | | unusable series | an unusable required series is UNKNOWN even beside a positive `ci.configured = false` (precedence A of PV-INTEGRITY-TOTALITY-001) | `test_an_unusable_series_is_unknown_even_beside_a_positive_not_configured` | ## Integrity: durable revision history (PV-HIST-002) diff --git a/src/devostasis/vitals/integrity.py b/src/devostasis/vitals/integrity.py index f01d2bb..1430dc2 100644 --- a/src/devostasis/vitals/integrity.py +++ b/src/devostasis/vitals/integrity.py @@ -161,8 +161,10 @@ def sample_strength(n: int) -> str | None: def _record_consistent(record: Any) -> bool: - """A revision record's history state is in the vocabulary and its contribution is the one PV-CI-UNIT-004 derives from it.""" - if not isinstance(record, dict) or not isinstance(record.get("history_state"), str) or record["history_state"] not in HISTORY_CONTRIBUTION: + """A revision record names its immutable revision, its history state is in the vocabulary, and its contribution is the one PV-CI-UNIT-004 derives from it.""" + if not isinstance(record, dict) or not isinstance(record.get("revision"), str) or not record["revision"]: + return False + if not isinstance(record.get("history_state"), str) or record["history_state"] not in HISTORY_CONTRIBUTION: return False if not isinstance(record.get("current_verdict"), (str, type(None))): return False @@ -188,7 +190,7 @@ def _unknown(obs: ObservationSet, history: dict[str, Any], diagnostics: list[str def _bearing(history: Reconciled, revisions: list[dict[str, Any]]) -> list[dict[str, Any]]: """The reconciled records of the current inventory that carry any verification history.""" - records = [history.records[r["revision"]] for r in revisions if r["revision"] in history.records] + records = [history.records[r.get("revision")] for r in revisions if isinstance(r, dict) and r.get("revision") in history.records] return [r for r in records if r["parents"] or r.get("unresolved")] diff --git a/tests/conformance/test_integrity.py b/tests/conformance/test_integrity.py index 3f94e04..1604aa7 100644 --- a/tests/conformance/test_integrity.py +++ b/tests/conformance/test_integrity.py @@ -300,3 +300,14 @@ def test_a_parent_level_failure_is_still_a_proven_failure(): integrity_inputs(obs, True, revs) result = integrity.evaluate(obs) assert (result.band, result.evaluation_status) == ("FAILING", "AVAILABLE") and result.derived["failed_count_14d"] == 1 + + +def test_a_record_without_its_revision_identity_is_a_defect_not_a_crash(): + """Durable history is keyed by immutable revision (PV-HIST-002): a record that names none cannot be counted or carried.""" + anonymous = revision("x", "2026-09-01T00:00:00Z", [parent("p", "VERIFY_PASS")], "VERIFY_PASS", "PASS_ONLY_OBSERVED") + del anonymous["revision"] + obs = obs_set() + integrity_inputs(obs, True, passing_revisions(4) + [anonymous]) + result = integrity.evaluate(obs) + assert (result.band, result.evaluation_status) == (None, "UNKNOWN") + assert any(code.startswith("REVISION_RECORD_INCONSISTENT") for code in result.diagnostics) From 96d10fc844732d196628e3a8f39737eab69fe871 Mon Sep 17 00:00:00 2001 From: Dreven Date: Thu, 1 Oct 2026 01:27:38 +0300 Subject: [PATCH 10/33] Close B3 and v0.2 in this repository's own targets register B3, durable revision history, is delivered by this release. v0.2 described the condition the roadmap had set for 0.2 (phases A and B closed, an outside consumer, no unadopted research); 0.2.0 ships before it at the owner's request, so the release target closes and the condition moves to a new target, v0.3, as the roadmap now says. Ids are never reused. The pull requests that advanced B3 stay linked after it closes: that is the state-neutral linkage of direction.bands.v2, and this repository is its first beneficiary. Co-Authored-By: Claude Opus 5.5 --- .devostasis/targets.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.devostasis/targets.json b/.devostasis/targets.json index 875d388..72103eb 100644 --- a/.devostasis/targets.json +++ b/.devostasis/targets.json @@ -20,7 +20,7 @@ { "id": "B3", "title": "Durable revision history across bundles", - "state": "open" + "state": "closed" }, { "id": "B4", @@ -45,6 +45,11 @@ { "id": "v0.2", "title": "Release 0.2: phases A and B closed, one outside consumer, no unadopted research unit", + "state": "closed" + }, + { + "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)", "state": "open" }, { From 2941142ebdffecce40d2a463af16238ea4f75036 Mon Sep 17 00:00:00 2001 From: Aleksei Bogun Date: Fri, 2 Oct 2026 15:53:20 +0300 Subject: [PATCH 11/33] 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 12/33] 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 13/33] 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 b86a1d2807e87ffc701c723b3369803b1850c131 Mon Sep 17 00:00:00 2001 From: Aleksei Bogun Date: Fri, 2 Oct 2026 17:52:52 +0300 Subject: [PATCH 14/33] Release 0.3.0: add verified Evidence to Action work scopes --- .devostasis/debt.json | 8 + .devostasis/targets.json | 5 + .devostasis/work-policy.json | 46 ++++ .github/dependabot.yml | 4 +- .github/workflows/ci.yml | 14 + .github/workflows/observe-self.yml | 4 +- .github/workflows/self-work.yml | 18 ++ .github/workflows/work-observe.yml | 126 +++++++++ CHANGELOG.md | 32 +++ README.md | 26 +- ROADMAP.md | 32 ++- docs/deployment.md | 9 +- docs/spec/README.md | 2 + docs/spec/conformance.md | 14 + docs/spec/work-evidence.md | 47 ++++ docs/spec/work-scope.md | 159 +++++++++++ docs/work-scopes.md | 146 +++++++++++ examples/work/bundle/evidence.json | 1 + examples/work/bundle/inventory.json | 1 + examples/work/bundle/manifest.json | 1 + examples/work/bundle/policy.json | 1 + examples/work/bundle/report.md | 17 ++ examples/work/bundle/scope.json | 1 + examples/work/evidence.json | 1 + examples/work/github-observe.yml | 22 ++ examples/work/gitlab-observe.yml | 64 +++++ examples/work/inventory.json | 1 + examples/work/policy.json | 1 + pyproject.toml | 2 +- scripts/make_work_examples.py | 48 ++++ src/devostasis/__init__.py | 2 +- src/devostasis/cli.py | 2 + src/devostasis/workscope/__init__.py | 3 + src/devostasis/workscope/bundle.py | 209 +++++++++++++++ src/devostasis/workscope/cli.py | 169 ++++++++++++ src/devostasis/workscope/collect.py | 376 +++++++++++++++++++++++++++ src/devostasis/workscope/evidence.py | 193 ++++++++++++++ src/devostasis/workscope/model.py | 233 +++++++++++++++++ src/devostasis/workscope/planner.py | 210 +++++++++++++++ tests/test_release_pins.py | 10 +- tests/test_workscope.py | 359 +++++++++++++++++++++++++ tests/test_workscope_adapters.py | 245 +++++++++++++++++ 42 files changed, 2846 insertions(+), 18 deletions(-) create mode 100644 .devostasis/work-policy.json create mode 100644 .github/workflows/self-work.yml create mode 100644 .github/workflows/work-observe.yml create mode 100644 docs/spec/work-evidence.md create mode 100644 docs/spec/work-scope.md create mode 100644 docs/work-scopes.md create mode 100644 examples/work/bundle/evidence.json create mode 100644 examples/work/bundle/inventory.json create mode 100644 examples/work/bundle/manifest.json create mode 100644 examples/work/bundle/policy.json create mode 100644 examples/work/bundle/report.md create mode 100644 examples/work/bundle/scope.json create mode 100644 examples/work/evidence.json create mode 100644 examples/work/github-observe.yml create mode 100644 examples/work/gitlab-observe.yml create mode 100644 examples/work/inventory.json create mode 100644 examples/work/policy.json create mode 100644 scripts/make_work_examples.py create mode 100644 src/devostasis/workscope/__init__.py create mode 100644 src/devostasis/workscope/bundle.py create mode 100644 src/devostasis/workscope/cli.py create mode 100644 src/devostasis/workscope/collect.py create mode 100644 src/devostasis/workscope/evidence.py create mode 100644 src/devostasis/workscope/model.py create mode 100644 src/devostasis/workscope/planner.py create mode 100644 tests/test_workscope.py create mode 100644 tests/test_workscope_adapters.py diff --git a/.devostasis/debt.json b/.devostasis/debt.json index f8fe7cd..9aa596f 100644 --- a/.devostasis/debt.json +++ b/.devostasis/debt.json @@ -2,6 +2,14 @@ "schema": "devostasis.debt.v1", "notes": "Registered maintenance obligations of this repository, read by Devostasis when debt.source = file. An item belongs here when the current code carries a cost, not when a feature is missing: planned work lives in targets.json and ROADMAP.md. 'opened' is the date the obligation started being carried, 'updated' the last time it changed; an open item untouched for 30 days is stale.", "items": [ + { + "id": "D-6", + "title": "GitLab work scopes cannot certify exact-head approvals from the aggregate approvals API", + "state": "open", + "opened": "2026-10-02", + "updated": "2026-10-02", + "cost": "The 0.3.0 companion reads approval capability but the standard GitLab response supplies users/times, not the reviewed commit. It therefore keeps exact-head approval UNKNOWN and cannot admit automatic merges. A revision-bound read-only profile and adopter integration validation are tracked in https://github.com/drevendev/Devostasis/issues/58; this debt does not block independent review, issue, research or analysis work." + }, { "id": "D-1", "title": "Conformance cases named in the specification have no executable fixture", diff --git a/.devostasis/targets.json b/.devostasis/targets.json index 61b990f..b25e971 100644 --- a/.devostasis/targets.json +++ b/.devostasis/targets.json @@ -47,6 +47,11 @@ "title": "Release 0.2: phases A and B closed, one outside consumer, no unadopted research unit", "state": "open" }, + { + "id": "E1", + "title": "Evidence to Action: verified deterministic consumer work scopes, five queues, findings and bounded handoff", + "state": "closed" + }, { "id": "v0.3", "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)", diff --git a/.devostasis/work-policy.json b/.devostasis/work-policy.json new file mode 100644 index 0000000..557018d --- /dev/null +++ b/.devostasis/work-policy.json @@ -0,0 +1,46 @@ +{ + "contract": "devostasis.work.v1", + "kind": "policy", + "version": "devostasis-work-1", + "actor": "drevendev", + "roles": ["all"], + "ttl_seconds": 21600, + "queue_order": ["review", "finish_merge", "implement_issue", "research", "analyze_code"], + "required_checks": ["Tests (Python 3.12)", "Tests (Python 3.13)", "Tests (Python 3.14)"], + "minimum_approvals": 1, + "allow_merge_train": false, + "read_budget": {"max_files": 40, "max_bytes": 400000}, + "priorities": { + "issue:58": {"rank": 10, "rationale": "Enable exact-head GitLab merge admission without weakening evidence."}, + "request:receipt-identity": {"rank": 20, "rationale": "The accepted identity adoption has an intermittent release-validation failure."} + }, + "criteria": [ + { + "id": "gitlab-exact-head-approval", + "issue": "58", + "acceptance": [ + "Define a read-only approval profile with exact reviewed SHA and explicit capability uncertainty.", + "Test retained approvals after new commits, dismissals, author reviews and collection races.", + "Verify it in the GitLab adopter before enabling merge admission." + ], + "paths": ["src/devostasis/workscope/collect.py", "src/devostasis/workscope/planner.py", "tests/test_workscope_adapters.py"], + "symbols": ["_gl_change", "merge_reasons"], + "dependencies": [], + "implementations": [], + "done": false + } + ], + "requests": [ + { + "id": "receipt-identity", + "queue": "research", + "question": "Map the accepted PV-BUNDLE-ID-003 adoption on issue #19 to its exact current code and historical dispatch requirements.", + "paths": ["src/devostasis/bundle.py", "src/devostasis/observations.py", "tests/test_rate_limits.py"], + "symbols": ["build_bundle", "Receipt"], + "acceptance": ["Produce a bounded adoption map preserving observed_at, legacy verification and byte-identical successor members; do not alter a Vital rule."], + "dependencies": [], + "owner": null + } + ], + "dispositions": [] +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0bff239..03ced88 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,7 +3,7 @@ updates: - package-ecosystem: "github-actions" directory: "/" # Feature and maintenance PRs target the active release, per CONTRIBUTING.md. - target-branch: "release/0.2.0" + target-branch: "release/0.3.0" schedule: interval: "weekly" day: "tuesday" @@ -15,7 +15,7 @@ updates: default-days: 7 - package-ecosystem: "pip" directory: "/" - target-branch: "release/0.2.0" + target-branch: "release/0.3.0" schedule: interval: "weekly" day: "tuesday" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fdc5324..0b8baca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,20 @@ jobs: run: devostasis vectors --quiet - name: Example bundle verifies from its own contents run: devostasis verify --bundle examples/sample-bundle + - name: Work companion replay and deterministic handoff + run: | + devostasis work verify --bundle examples/work/bundle + devostasis work replay --bundle examples/work/bundle + devostasis work slice --bundle examples/work/bundle --at 2026-10-02T12:00:00Z --limit 2 + devostasis work build --inventory examples/work/inventory.json --policy examples/work/policy.json --evidence examples/work/evidence.json --store /tmp/work-a > /tmp/work-a.json + devostasis work build --inventory examples/work/inventory.json --policy examples/work/policy.json --evidence examples/work/evidence.json --store /tmp/work-b > /tmp/work-b.json + python - <<'PY' + import json, subprocess + a = json.load(open('/tmp/work-a.json')) + b = json.load(open('/tmp/work-b.json')) + assert a['bundle_id'] == b['bundle_id'] + subprocess.run(['devostasis', 'work', 'verify', '--bundle', a['path']], check=True) + PY - name: Determinism check on the example observations run: | a=$(devostasis build --observations examples/observations.json --store /tmp/store-a --planning milestones --debt-label type:refactor --debt-mapping-version example-1 | head -n 1 | awk '{print $2}') diff --git a/.github/workflows/observe-self.yml b/.github/workflows/observe-self.yml index c444ea4..856218d 100644 --- a/.github/workflows/observe-self.yml +++ b/.github/workflows/observe-self.yml @@ -4,7 +4,7 @@ # # jobs: # vitals: -# uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.2.0 +# uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.3.0 # with: # debt-labels: "type:debt" # @@ -24,7 +24,7 @@ on: devostasis-ref: description: Git ref of Devostasis to install (tag, branch or commit) type: string - default: v0.2.0 + default: v0.3.0 planning-source: description: milestones, file or none type: string diff --git a/.github/workflows/self-work.yml b/.github/workflows/self-work.yml new file mode 100644 index 0000000..b6f2cd2 --- /dev/null +++ b/.github/workflows/self-work.yml @@ -0,0 +1,18 @@ +# Scheduled/manual only: a release merge legitimately precedes its tag. +# The reusable observer installs the released engine; observing that merge +# before it is tagged would manufacture an install failure (see issue #18). +name: Observe own work scopes +on: + schedule: + - cron: "23 */6 * * *" + workflow_dispatch: +permissions: + contents: read + issues: read + pull-requests: read + checks: read +jobs: + work: + uses: ./.github/workflows/work-observe.yml + with: + policy-path: .devostasis/work-policy.json diff --git a/.github/workflows/work-observe.yml b/.github/workflows/work-observe.yml new file mode 100644 index 0000000..a7ad399 --- /dev/null +++ b/.github/workflows/work-observe.yml @@ -0,0 +1,126 @@ +name: Devostasis work observation + +on: + workflow_call: + inputs: + devostasis-ref: + type: string + default: v0.3.0 + policy-path: + type: string + default: .devostasis/work-policy.json + max-requests: + type: number + default: 100 + max-pages: + type: number + default: 10 + seconds: + type: number + default: 120 + history-runner: + description: Optional label of a protected self-hosted runner with a private durable volume + type: string + default: "" + history-store: + type: string + default: /var/lib/devostasis-work + +permissions: + contents: read + issues: read + pull-requests: read + checks: read + +concurrency: + group: work-observation-${{ github.repository }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + observe: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install released companion + env: + DEVOSTASIS_REF: ${{ inputs.devostasis-ref }} + run: python -m pip install "git+https://github.com/drevendev/Devostasis.git@${DEVOSTASIS_REF}" + - name: Collect and publish a bounded scope + env: + DEVOSTASIS_GITHUB_TOKEN: ${{ github.token }} + POLICY: ${{ inputs.policy-path }} + REPOSITORY: ${{ github.repository }} + CURRENT_REVISION: ${{ github.event.pull_request.head.sha || github.sha }} + IS_CANONICAL: ${{ !github.event.pull_request && github.ref_name == github.event.repository.default_branch }} + MAX_REQUESTS: ${{ inputs.max-requests }} + MAX_PAGES: ${{ inputs.max-pages }} + SECONDS_BUDGET: ${{ inputs.seconds }} + run: | + mkdir -p work-output + context=CANDIDATE + if [ "$IS_CANONICAL" = "true" ]; then context=CANONICAL; fi + python -m devostasis work run --repo "$REPOSITORY" --policy "$POLICY" \ + --context "$context" --revision "$CURRENT_REVISION" --store work-output/history \ + --max-requests "$MAX_REQUESTS" --max-pages "$MAX_PAGES" --seconds "$SECONDS_BUDGET" \ + > work-output/result.json 2> work-output/failure.txt + python - <<'PY' + import json, os, pathlib, subprocess, sys + result = json.loads(pathlib.Path('work-output/result.json').read_text()) + bundle = pathlib.Path(result['path']) + subprocess.run([sys.executable, '-m', 'devostasis', 'work', 'verify', '--bundle', str(bundle)], check=True) + subprocess.run([sys.executable, '-m', 'devostasis', 'work', 'slice', '--bundle', str(bundle), '--limit', '20', '--output', 'work-output/slice.json'], check=True) + with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as stream: + stream.write((bundle / 'report.md').read_text()) + PY + - uses: actions/upload-artifact@v4 + if: always() + with: + name: devostasis-work-${{ github.run_id }} + path: work-output/ + retention-days: 30 + if-no-files-found: warn + + history: + needs: observe + if: ${{ inputs.history-runner != '' && !github.event.pull_request && github.ref_name == github.event.repository.default_branch && github.ref_protected }} + continue-on-error: true + runs-on: [self-hosted, "${{ inputs.history-runner }}"] + concurrency: + group: canonical-work-history-${{ github.repository }} + cancel-in-progress: false + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install released verifier + env: + DEVOSTASIS_REF: ${{ inputs.devostasis-ref }} + run: python -m pip install "git+https://github.com/drevendev/Devostasis.git@${DEVOSTASIS_REF}" + - uses: actions/download-artifact@v4 + with: + name: devostasis-work-${{ github.run_id }} + path: canonical-input + - name: Verify identity and append to protected history + env: + HISTORY_STORE: ${{ inputs.history-store }} + EXPECTED_PROJECT: ${{ github.repository_id }} + EXPECTED_REVISION: ${{ github.sha }} + run: | + python - <<'PY' + import os, pathlib + from devostasis.workscope.bundle import publish, read, verify + bundles = list(pathlib.Path('canonical-input/history/work').glob('*/bundles/*')) + if len(bundles) != 1: + raise ValueError('expected one verified run bundle') + bundle = bundles[0] + subject = verify(bundle)['scope.json']['subject'] + if subject['provider'] != 'github' or subject['endpoint'] != 'https://api.github.com' or subject['project_id'] != os.environ['EXPECTED_PROJECT'] or subject['revision'] != os.environ['EXPECTED_REVISION'] or subject['context'] != 'CANONICAL': + raise ValueError('canonical history binding mismatch') + print(publish(os.environ['HISTORY_STORE'], read(bundle))) + PY diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e49773..f122c4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,38 @@ 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.3.0 (unreleased) + +Evidence to Action, target E1: a separate deterministic consumer companion +(`devostasis.work.v1`, replay engine `devostasis.work-engine.v1`). + +- Five typed queues: review, finish_merge, implement_issue, research and + analyze_code. Explicit consumer priorities, exact revisions, independent + reviewer/owner roles, dependencies, acceptance and bounded read sets. +- Read-only GitHub and GitLab work collectors with request/time/page/byte + budgets, capability receipts, conditional cache partitioning and explicit + completeness. GitHub collects exact-head review verdicts, checks/statuses, + threads and merge-queue state. GitLab approval SHA uncertainty stays UNKNOWN + and is tracked in #58; no aggregate approval is fabricated as an exact-head + verdict. This companion does not claim the core Phase C adapter/carrier work. +- Optional revision-bound SARIF, JUnit, Cobertura and measured-performance + profiles, with raw source digests, path admission, scoped dispositions, + malformed/missing/stale evidence recovery and offline replay. +- Immutable work bundles, verified latest pointers, process-locked atomic + publication, candidate isolation and backfill protection. Bounded role + slices have scope-bound cursors, overflow and explicit exclusion reasons. +- `devostasis work policy/import/collect/build/run/verify/replay/slice/explain/recheck`. + Recheck binds consumer policy and refreshes only selected sources before + returning eligibility; no command claims or executes work. +- GitHub reusable read-only workflow with optional protected durable history + runner, GitLab canonical/candidate CI recipes, adopter guide, public frozen + example and implementation-owned regression cases. The public GitHub live + pilot is distinct from the outstanding external calibration target B7. + +Seven Vitals, their thresholds, bands, gauges and demand ordering retain their +contracts. This branch includes the still-pending 0.2.0 adoption/readiness +work below; neither version is represented as already tagged or released. + ## 0.2.0 (unreleased) Release-readiness review, 2026-10-02: diff --git a/README.md b/README.md index 962fc21..b6eb6ad 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,28 @@ The name is *development* plus *homeostasis*: the goal is a stable, honest reading of where a project actually stands, so that people and autonomous development systems can react to it. +The **0.3.0 Evidence to Action** companion adds five bounded work queues: +`review`, `finish_merge`, `implement_issue`, `research`, `analyze_code`. +It binds tasks to exact revisions, explicit consumer priorities, dependencies, +read budgets and acceptance; verifies immutable scope bundles offline; and +rechecks selected source state before execution. Optional SARIF, JUnit, +Cobertura and performance reports provide scoped analysis triggers. Collectors +read GitHub and GitLab; unknown capabilities remain explicit. See the +[adopter walkthrough](docs/work-scopes.md), +[consumer contract](docs/spec/work-scope.md) and +[frozen example](examples/work/bundle/report.md). + +```sh +devostasis work policy --actor YOUR_LOGIN --output policy.json +devostasis work run --repo OWNER/REPO --policy policy.json --store /private/work-history +devostasis work verify --bundle examples/work/bundle +devostasis work slice --bundle examples/work/bundle --at 2026-10-02T12:00:00Z --limit 2 +``` + +Configure the consumer policy's required checks and criteria before use. +Queues propose work; they grant no merge/deploy permission and execute no +repository instructions. Seven Vitals keep their own contracts and ordering. + ## Why this exists Repository dashboards usually fail in one of four ways: @@ -155,7 +177,7 @@ comes first is the consumer's policy, and no accepted contract defines it. Requires Python 3.12 or newer. The runtime uses the standard library only. ```bash -pip install git+https://github.com/drevendev/devostasis@v0.2.0 +pip install git+https://github.com/drevendev/devostasis@v0.3.0 ``` Observe one repository (a GitHub token is read from `DEVOSTASIS_GITHUB_TOKEN`, @@ -214,7 +236,7 @@ permissions: jobs: vitals: - uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.2.0 + uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.3.0 decide: needs: vitals runs-on: ubuntu-latest diff --git a/ROADMAP.md b/ROADMAP.md index 3723810..65e8e3a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,6 +6,26 @@ started and work that cannot start. ## Release state and next steps — 2026-10-02 +**0.3.0 Evidence to Action is implemented on this release branch**, with its +own consumer contract and target E1. The companion supplies five queues, +GitHub/GitLab work context, revision-bound producer findings, durable scope +history, replay, bounded handoff and read-only pre-execution checks. See +[docs/work-scopes.md](docs/work-scopes.md) for the complete adopter path. +The public GitHub pilot read all five then-open PRs in 38 successful requests. +GitLab exact-head approvals remain explicit UNKNOWN, tracked in +[#58](https://github.com/drevendev/Devostasis/issues/58); its deployment pilot +belongs to the independently integrating GitLab project. This implements a +companion consumer contour, not the core accepted Phase C Instrument carrier +or the unaccepted Evidence Observatory composition. + +This is an unreleased increment. E1 being implemented does not close B1, B7, +the earlier target `v0.3` (the deferred Phase B/adoption completion bar), or +v1.0. Their permanent ids and original acceptance are preserved. Review and +release the carried 0.2.0 fixes and this increment; then prioritize sustained +observation (#32), remaining conformance (#23), compatibility adoption (#34) +and an external calibration consumer. Do not substitute the public integration +check for that sustained outside-consumer evidence. + 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 @@ -35,7 +55,7 @@ The next work is ordered by the evidence each step makes possible: [#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 +4. **Adopt the implemented bounded work companion.** The 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 @@ -43,11 +63,13 @@ The next work is ordered by the evidence each step makes possible: [#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. + CI and durable handoff recipe. This branch implements the separate + `devostasis.work.v1` consumer contract and workflows. These are not + accepted core Vital/Instrument semantics; task selection remains outside + the observational engine. Adopter policy, sustained external deployment + and the revision-bound GitLab approval capability are the remaining steps. -The recommended next major product increment is **Evidence to Action**: +The major product increment in this branch 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 diff --git a/docs/deployment.md b/docs/deployment.md index 9c87208..7d0ef28 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,5 +1,10 @@ # Deployment +For the 0.3.0 Evidence to Action companion, five queues, producer reports, +GitHub/GitLab CI and protected durable history, see +[work-scopes.md](work-scopes.md). Its store and contract are separate from the +Vital bundles described here. + There are two ways to run Devostasis, and they complement each other: | | Fleet observer | Self-observation | @@ -24,7 +29,7 @@ permissions: jobs: vitals: - uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.2.0 + uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.3.0 with: debt-labels: "type:debt" # optional: issue labels that mark debt items # planning-source: file # optional: targets register instead of milestones @@ -161,7 +166,7 @@ jobs: with: python-version: "3.12" - name: Install Devostasis - run: python -m pip install --quiet "git+https://github.com/drevendev/devostasis@v0.2.0" + run: python -m pip install --quiet "git+https://github.com/drevendev/devostasis@v0.3.0" - name: Observe every configured project id: run continue-on-error: true diff --git a/docs/spec/README.md b/docs/spec/README.md index fc38fba..ef04b5e 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -18,6 +18,8 @@ conformance tests. | [github-adapter.md](github-adapter.md) | What the GitHub adapter collects, how it maps failures and caps, what it does not collect | | [conformance.md](conformance.md) | Conformance case identifiers and where each is implemented | | [vectors.md](vectors.md) | The executable conformance vector format and its runner | +| [work-scope.md](work-scope.md) | Separate consumer work-scope v1, five queues, replay, history and handoff | +| [work-evidence.md](work-evidence.md) | Optional revision-bound SARIF, JUnit, Cobertura and performance profiles | | [PROVENANCE.md](PROVENANCE.md) | Contract identifiers and the research units they come from | Vocabulary used throughout: diff --git a/docs/spec/conformance.md b/docs/spec/conformance.md index 88f2539..b6ff873 100644 --- a/docs/spec/conformance.md +++ b/docs/spec/conformance.md @@ -559,3 +559,17 @@ about, so that family can be materialized against it (issue #20); `variants` and the dependency-group assertions close the T8 and part of the T6 gap of issue #23, while T3 and the bundle and store cases still need a surface this version does not publish. + +## Companion regression evidence (implementation-owned) + +These DEV-WORK families exercise `devostasis.work.v1`; they do not replace +the outstanding research cases above or claim acceptance of a Vital rule. + +| Family | Proof | +| --- | --- | +| queues and identity | `test_work_five_queues_and_bounded_acceptance`, `test_work_determinism_ids_and_explicit_priority` | +| exact-head admission | `test_work_merge_admission_matrix`, `test_work_missing_cannot_admit_merge`, `test_work_stale_verdict_dismissal_and_self_review` | +| source and producer gaps | `test_work_report_profiles`, `test_work_malformed_and_unavailable_reports_are_not_zero`, `test_work_report_wrong_repo_and_revision`, `test_work_gitlab_missing_capabilities_are_explicit` | +| bounded handoff | `test_work_cursor_exhaustive_binding_and_expiry`, `test_work_selected_refresh_does_not_discover_whole_repository`, `test_work_cli_offline_end_to_end_and_recheck` | +| durable replay | `test_work_bundle_replay_and_forged_rehashed_projection_rejected`, `test_work_store_backfill_candidate_rename_and_latest_binding`, `test_work_store_lock_and_failed_pointer_write_preserve_history` | +| transport | `test_work_redirect_cannot_forward_credentials`, `test_work_transport_refuses_mutation`, `test_work_conditional_304_replays_body_without_credential_crossing` | diff --git a/docs/spec/work-evidence.md b/docs/spec/work-evidence.md new file mode 100644 index 0000000..bed8f89 --- /dev/null +++ b/docs/spec/work-evidence.md @@ -0,0 +1,47 @@ +# Optional report profiles — devostasis.work.v1 + +`evidence.json` is a separate companion input: contract, kind=evidence, +sources (at most 100). Missing evidence is not a zero score or a proven clean +repository. An empty list says no producer was selected. An adopter expecting +a producer must supply UNAVAILABLE when it did not run. + +Each source has profile, producer, version, subject, observed_at, +COMPLETE/PARTIAL/UNAVAILABLE status, nullable reason and nullable report. +Report contains canonical base64 source bytes and their SHA-256 digest. The +envelope binds exact immutable project identity and commit, producer/profile +version, collection time and completeness. Individual reports are bounded to +2 MiB, 10,000 normalized locations, one source per producer/profile. UNAVAILABLE +requires a reason and no report; PARTIAL requires a reason. Digest mismatches +and duplicate envelopes are inadmissible. Malformed producer payloads become +explicit recovery gaps; no partial parser results survive a parser failure. + +Paths must be plain repository-relative POSIX paths: no absolute path, drive, +backslash, empty/dot/parent component, URI or encoded path. Source locations +have positive line numbers and optional symbols. Unknown XML DTD/entities, +duplicate JSON members and non-finite numbers are rejected. Reports do not +provide commands, permissions or business priorities. Raw messages are not +used as instructions or emitted as trusted Markdown. An analysis task means +inspect and verify the observed trigger, not that a bug was proved. + +| Profile | Admitted evidence and verification | +| --- | --- | +| sarif.v1 | SARIF 2.1.0 runs/results with ruleId and physical repository location; pass/notApplicable ignored; unresolved uriBaseId refused | +| junit.v1 | testsuite/testsuites, testcase with file, line, name and failure/error/flakyFailure/flakyError; reproduce the exact test | +| cobertura.v1 | coverage with a positive lines-valid denominator; class filename and instrumented line number/hits; zero-hit lines trigger behavior/test assessment | +| performance.v1 | measurements with name/path/line, positive integer baseline, integer current, consumer limit_percent, unit and immutable baseline_revision; regression uses integer cross multiplication | + +Cobertura percentages and JUnit timings are not used in scoring. Performance +units/benchmark methodology are declared by producer/version; the threshold +is explicit consumer evidence, not a Vital rule. Missing location or coverage +denominator cannot produce a current analysis item. Duplicate finding locations +within a source reject that source rather than hiding conflicting evidence. +Finding identity hashes profile, producer, rule, path, line and symbol; exact +revision and producer version bind the occurrence separately. Changed +revision/expiry reconsiders a disposition. Same-revision acknowledgements +remain explicit exclusions with their reference. IMPLEMENTING suppresses work +only with a live correlated implementation. + +Wrong repo/revision and expired/future reports produce bounded recovery gaps, +not findings for the current revision. Reports are verified and re-normalized +offline from persisted bytes. See [work-scope.md](work-scope.md) for execution +boundaries and [../work-scopes.md](../work-scopes.md) for producer examples. diff --git a/docs/spec/work-scope.md b/docs/spec/work-scope.md new file mode 100644 index 0000000..e46c169 --- /dev/null +++ b/docs/spec/work-scope.md @@ -0,0 +1,159 @@ +# Evidence to Action companion — devostasis.work.v1 + +This is an implementation-owned consumer contract introduced in 0.3.0 in +response to #54–56. It is separate from the seven Vitals and +`devostasis.demand.v2`. It does not claim acceptance as a research Vital or +Instrument contract. Gauges, bands, urgency inferred from titles and models +never participate in task ordering. Runtime dependencies remain empty. + +The public Python surface is `devostasis.workscope.planner.project`, +`evidence.normalize`, `bundle.build/verify/publish/latest/slice_scope`, +and `collect.Client/collect`. Inputs and outputs are JSON values; filesystem +bundles use `devostasis.canon.v1`. Errors are `ScopeError` or strict canonical +decoder errors. Unknown fields, duplicate ids, ambiguous timestamps and +non-integer policy numbers are refused. All timestamps must include a zone; +microseconds are preserved. Commit ids are full lowercase SHA-1/SHA-256. + +## Input authority + +`inventory.json` has `contract`, `kind=inventory`, `subject`, `observed_at`, +`changes`, `issues`, `selection`, `receipts`. Subject has provider (`github` +or `gitlab`), HTTPS API endpoint, immutable project id as a string, locator, +exact content revision and `CANONICAL`/`CANDIDATE` context. Endpoint and +immutable project id define project identity; renames do not split history. + +A collection has `status=COMPLETE|PARTIAL|UNAVAILABLE`, `items`, `reasons`. +UNAVAILABLE has no items. COMPLETE has no missing-evidence reasons. Empty +COMPLETE means empty **within the declared selection**. Issues are read from +the explicit criteria/dependency register, not the whole issue tracker. +`selection.issues` names those ids; `selection.changes` distinguishes +`OPEN_AND_REGISTERED` from the narrow `SELECTED` refresh used by recheck. +Discovery, collection budgets and source authentication do not make this a +cryptographically signed statement about a forge. Consumers trust their +collector and transport; hashes bind stored inputs and deterministic replay. + +Canonical acquisition receipts record path, semantic response status and body +digest. A validated 304 replay and its equivalent 200 response have the same +receipt. Invocation timings, network byte counts and physical 200/304 status +remain in `Client.telemetry` outside the canonical inventory; they cannot +change a work-bundle id. Source failure statuses remain evidence-bearing. + +A change has id, title, OPEN/CLOSED/MERGED state, author, owners, requested reviewers, exact head +and base ids, updated_at, nullable booleans draft/conflict/merge_train, and +collections reviews/checks/threads/files. Reviews bind id, actor, revision, +APPROVED/CHANGES_REQUESTED/DISMISSED/COMMENTED and at. Checks bind id, name, +revision, PASS/FAIL/PENDING/MANUAL/UNKNOWN and at. Threads have id and resolved; +files contain repository-relative paths. An issue has id, title, OPEN/CLOSED +state, owners and updated_at. Future source events are not admitted. + +`policy.json` has contract, kind=policy, explicit version and actor, roles, +ttl_seconds (1–86400), queue_order (all five exactly once), required_checks, +minimum_approvals (1–100), allow_merge_train, read_budget, priorities, +criteria, requests and dispositions. Policy is consumer authority; no issue +body, report or source title changes it. Required check names must be selected +by the adopter, including provider-required checks. An empty list explicitly +declares no check gate; it cannot certify branch protection or grant a merge. +Changing policy content invalidates its digest even if the version was reused. + +Priority keys are `change:`, `issue:`, `request:` or finding +ids; each contains integer rank (smaller first) and explicit rationale. +Unspecified sources receive rank 1000 and an explicit absence rationale. +`read_budget` bounds max_files and max_bytes; consumers enforce the byte cap +when reading the named paths at the recorded revision. This library does not +read code or execute repository instructions. + +Criteria have permanent id, numeric issue id, nonempty acceptance, paths, +symbols, numeric issue dependencies, explicitly correlated implementation +change ids, and done. Sibling criteria remain independent. A closed issue, +done criterion or correlated OPEN/MERGED implementation excludes new work. +An absent referenced implementation is UNKNOWN, not evidence of no work. +Unregistered equivalence is not inferred from natural language. Requests have +id, research/analyze_code queue, question/observed trigger, paths, symbols, +nonempty expected acceptance, dependencies and nullable owner. Code analysis +requires named paths. Dispositions bind finding id, exact revision, expiry, +ACKNOWLEDGED/IMPLEMENTING and reference; implementing references must resolve +to a currently open implementation to suppress recurrence. + +## Projection and eligibility + +Each item contains stable id, queue/source/criterion, subject, exact revision, +title, action, implementation_owner, executor, explicit priority, +continuation, eligibility, reasons, dependencies, bounded read_set, +acceptance, input/evidence digests, optional merge_gate and fingerprint. +Stable identity hashes immutable project identity + queue + source + criterion. +Revision, freshness and evidence are separate bindings; a new commit keeps +the task id but invalidates its earlier scope. Fingerprints include bindings. + +| Queue | Admission | +| --- | --- | +| review | Independent executor, outstanding verdict on the exact head, complete files/review inventory, non-draft | +| finish_merge | Continue an implementation; distinguish merge readiness from rework eligibility | +| implement_issue | Explicit unfulfilled criterion, no correlated equivalent implementation, resolved ownership/dependencies | +| research | Explicit question or one bounded recovery request per missing source | +| analyze_code | Named paths/symbols, observed producer finding or explicit trigger, verification objective | + +Merge gates require exact-head independent approvals (latest decisive review +per actor; dismissals invalidate approvals), explicit required checks passing +on that head, resolved discussions, complete files/reviews/checks/threads, +known non-draft/non-conflicting state and explicit merge-train policy. +An unknown required fact produces UNKNOWN, never merge-ready. A known red +check, conflict or requested change can produce READY **rework**, while +`merge_gate` stays BLOCKED. Foreign or ambiguous implementation ownership +prevents execution; executor and owner are distinct fields. The planner +does not infer permission from these fields. Current branch protection, +CODEOWNERS and merge authority must still be enforced by the execution client. + +Sort order is eligibility (READY, BLOCKED, UNKNOWN), business rank, +continuation, declared queue preference, stable id. No aggregate health score +is introduced. Read-set overflow is explicit UNKNOWN. Incomplete collections +retain bounded evidence recovery; a recovery item never requests a repo-wide +search. Expired inventories cannot produce executable normal work. + +## Bundles, history and handoff + +Members are inventory.json, policy.json, evidence.json, scope.json, report.md +and manifest.json. The manifest identifies `devostasis.work-engine.v1`, exact +member digests and an optional externally verified Vitals reference. Its id +is SHA-256 of the canonical preimage without bundle_id. Replay rebuilds every +derived member from stored input and clock and compares all bytes; rehashing +a forged scope does not make it valid. Unsupported contract/engine versions +fail closed. Future incompatible changes must get new contract/engine ids; +v1 replay stays available. Existing Vitals bundles and renderers keep their +existing contracts. Optional Vitals anchors are provenance references, not +an eighth input to priority; verification of a work bundle does not certify +the availability of that external bundle. + +Store: `work//bundles//` plus latest.json. +Verified immutable publication occurs under an OS process lock, with staging +and fsync before rename. Identical retries are idempotent. Backfills remain +history; only a strictly newer CANONICAL observation advances latest. Different +canonical bundles at one timestamp are rejected. Candidate bundles never +replace canonical latest. Latest is a pointer bound to bundle id, manifest +digest and project, and is reverified on read. A failed pointer write leaves +the prior verified latest and recoverable immutable history. This is a local +filesystem transaction, not a distributed object-store lock or authenticity +signature. Use CI serialization for replication to a separate history repo. + +`work slice` returns a bounded role-filtered page, total, overflow, exclusions, +coverage and a cursor bound to scope digest, role and offset. Changed scope or +role rejects the cursor. Expired slices return one correlated recovery request. +EMPTY, INCOMPLETE, EXPIRED and AVAILABLE are distinct. A consumer verifies +the bundle, confirms its expected project/actor/policy, then uses `work recheck` +with its current policy file. Recheck reads only the selected source, +correlated implementations and dependencies; it refuses changed revision, +owner, policy, action, stale evidence or lost eligibility. No command claims, +dispatches, labels, comments, closes, merges or deploys. Source state can change +after recheck; the execution client must condition its mutation on the exact +revision and enforce authority immediately before acting. + +## Executable evidence + +Implementation-owned regression cases are in `tests/test_workscope.py` and +`tests/test_workscope_adapters.py`; they are not research conformance ids. +They cover five queues, explicit priority, stable ids, stale/dismissed/self +approvals, checks, threads, conflicts, drafts, trains, ownership, dependencies, +sibling criteria, malformed/stale reports, dispositions, replay forgery, +cursor binding, backfills, candidate isolation and failed history writes. +The public fixture `examples/work/bundle` replays offline, regenerated by +`scripts/make_work_examples.py`. Adoption and live checks are documented in +[../work-scopes.md](../work-scopes.md). diff --git a/docs/work-scopes.md b/docs/work-scopes.md new file mode 100644 index 0000000..efcad8a --- /dev/null +++ b/docs/work-scopes.md @@ -0,0 +1,146 @@ +# Evidence to Action in 0.3.0 + +The companion turns recorded work and findings into five bounded queues. It +can collect GitHub or GitLab work context; it does not replace the core Vital +adapter or implement the accepted Instruments carrier. The schemas are +implementation-owned `devostasis.work.v1`, described in +[spec/work-scope.md](spec/work-scope.md) and +[spec/work-evidence.md](spec/work-evidence.md). No command executes work. + +## Offline walkthrough + +Install the checkout with `python -m pip install -e .`. The committed example +is synthetic, public and frozen at 2026-10-02T12:00:00Z: + +```sh +devostasis work verify --bundle examples/work/bundle +devostasis work replay --bundle examples/work/bundle +devostasis work slice --bundle examples/work/bundle --at 2026-10-02T12:00:00Z --limit 2 +devostasis work build --inventory examples/work/inventory.json --policy examples/work/policy.json --evidence examples/work/evidence.json --store /tmp/work-history +``` + +The slice includes its next cursor and overflow. Pass `--cursor ` and +the same role to get the next page. Roles are reviewer, developer, researcher, +analyst or all. `work explain --bundle --item ` prints source, +bindings, reasons, read budget and acceptance. Without the frozen `--at`, an +expired example correctly returns a recovery request. New builds from identical +inputs and clock have identical ids and bytes, regardless of output directory. + +## Adopt on GitHub + +```sh +devostasis work policy --actor YOUR_LOGIN --output .devostasis/work-policy.json +# Review this policy: version, business priorities, required check names, +# criteria, correlated implementations, ownership and declared read budgets. +devostasis work run --repo OWNER/REPO --policy .devostasis/work-policy.json --store /private/work-history --cache /private/work-cache --max-requests 100 --max-pages 5 --seconds 120 +``` + +Token resolution uses DEVOSTASIS_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN or the +local `gh` session; values are never persisted. Use read permissions for +contents, pull requests, issues and checks. REST reads project/default commit, +open and registered PRs, exact-head reviews, checks/statuses and files. GraphQL +uses a generated **query** for exact-head review threads and merge-queue state; +POST here is read-only. GraphQL errors or permission gaps are UNKNOWN. +Anonymous public REST cannot certify discussion completeness. + +This repository carries its own explicit policy in `.devostasis/work-policy.json` +and a six-hour/manual self-work workflow. Its actor is the repository owner, +and its register names the new GitLab capability debt and the existing receipt +identity adoption question. The self-work workflow starts only after release; +scheduled/manual invocation avoids the legitimate merge-before-tag window. + +Before executing a selected proposal, verify its expected project identity, +read only the declared files/symbols at the recorded revision within its byte +budget, and run: + +```sh +devostasis work recheck --bundle BUNDLE --item ITEM_ID --expected-project-id YOUR_IMMUTABLE_PROJECT_ID --actor YOUR_LOGIN --policy-version YOUR_POLICY_VERSION --policy .devostasis/work-policy.json --max-requests 30 --seconds 60 +``` + +Exit 0 means the selected proposal retained eligibility; 3 means invalidated; +2 means admission or refresh failed. Supply the trusted immutable project id, +and for +GitLab supply `--provider gitlab --endpoint YOUR_API_ENDPOINT` as well. +Recheck is read-only and cannot grant merge/deploy authority. Apply provider protection/permissions and conditional +exact-head mutations in the execution client. New checks, claims, dependencies +or actions may require a rebuilt scope. For offline negative cases supply +`--inventory` and an explicit `--at`. + +[examples/work/github-observe.yml](../examples/work/github-observe.yml) calls +the reusable workflow after release. It runs after pushes, periodically and +for candidate PRs. It is non-gating, uses read credentials and uploads failure +receipts even when it cannot bind project identity. Candidate work cannot be +promoted to canonical latest. Artifact retention is **not** durable history: +for durable publication pass `history-runner` (a protected self-hosted runner +label) and `history-store` (its private mounted volume). The separate serialized +history job admits only a verified bundle matching the calling project id, +revision and CANONICAL context on a protected default branch. It has no forge +write token. Infrastructure can back up this volume to a separate private +history repository/object store. Keep writer credentials out of the +observer and out of PR jobs. Never commit generated history to the observed +branch. The core `observe-self` workflow continues to provide Vitals. + +## Producer profiles + +Collect context at the exact commit the report inspected, then import: + +```sh +devostasis work import --inventory inventory.json --profile sarif.v1 --producer semgrep --producer-version declared-profile-1 --report results.sarif --output evidence.json +devostasis work import --inventory inventory.json --evidence evidence.json --profile junit.v1 --producer pytest --producer-version pytest-profile-1 --report tests.xml --output evidence.json +devostasis work import --inventory inventory.json --evidence evidence.json --profile cobertura.v1 --producer coveragepy --producer-version coverage-profile-1 --report coverage.xml --output evidence.json +devostasis work build --inventory inventory.json --policy policy.json --evidence evidence.json --store /private/work-history +``` + +The importer does not discover the report's commit: **the caller must attest +the envelope's exact commit** from its CI job, not relabel an older report. +SARIF locations must resolve to repository-relative physical paths without +uriBaseId. JUnit producers must include testcase file/name/line for failures +(a producer omitting file yields UNKNOWN instead of a guessed path). Configure +your JUnit exporter accordingly. Cobertura needs lines-valid and line hits; +an unavailable denominator is UNKNOWN. Include a reasoned UNAVAILABLE source +when expected instrumentation is absent. Performance input is a JSON object +with a measurements array of name/path/line/baseline/current/limit_percent/ +unit/baseline_revision records; integer values avoid rounding ambiguity. +Reports are embedded with their digests for offline replay; secrets must not +be included in producer artifacts. There is no command interpretation. + +## GitLab integration + +```sh +devostasis work run --provider gitlab --endpoint https://gitlab.example/api/v4 --repo 123 --policy policy.json --store /private/work-history --max-requests 100 --max-pages 5 --seconds 120 +``` + +Use numeric project ids and DEVOSTASIS_GITLAB_TOKEN with `read_api` (or an +explicit `--token-env`). Endpoint capability receipts reveal rejected calls; +CI_JOB_TOKEN is not assumed sufficient. The adapter reads MRs, exact-commit +pipeline jobs, discussions, diffs and declared issues/dependencies. Diff +count/result caps stay PARTIAL. Merge trains are probed through their active +inventory; missing licensed/permission capability stays UNKNOWN. The standard +aggregate approvals API has no reviewed SHA: **this adapter never fabricates +exact-head approvals**. MR context and issue/research/analysis scopes are usable; +exact-head review completeness and automatic merge admission need the later +revision-bound approval profile tracked in #58. + +[examples/work/gitlab-observe.yml](../examples/work/gitlab-observe.yml) supplies +protected canonical and isolated candidate jobs, serialization, bounded cost, +failure artifacts, six-hour cadence guidance and a mounted private durable +store. Configure runner volumes/tags and read credentials in the adopter; +the candidate runner has neither the volume nor writer credentials. The store +is append-only; the current default SHA is checked before canonical collection. +A project moved during collection is not relabelled as fresh. Infrastructure +replication remains separate from the read adapter. A private GitLab pilot is +owned by its integrating project; this release validates fake transport and +uses GitHub for its live pilot. + +## Validation record + +On 2026-10-02, the authenticated read-only GitHub pilot on +drevendev/Devostasis collected all five then-open PRs in 38 successful API +requests. Reviews, checks/statuses, file inventories and GraphQL threads were +COMPLETE; draft/conflict/merge-queue facts were typed. The inventory built a +verified scope, replayed offline and preserved independent reviewer and owner +roles. This is a public project integration check, not the external sustained +calibration pilot B7. No repository object was mutated. CI additionally +verifies the synthetic example and repeated builds across supported Python +versions. Private GitLab credentials and deployment were intentionally not +assumed. diff --git a/examples/work/bundle/evidence.json b/examples/work/bundle/evidence.json new file mode 100644 index 0000000..8ddf48b --- /dev/null +++ b/examples/work/bundle/evidence.json @@ -0,0 +1 @@ +{"contract":"devostasis.work.v1","kind":"evidence","sources":[{"observed_at":"2026-10-02T12:00:00Z","producer":"pytest","profile":"junit.v1","reason":null,"report":{"base64":"PHRlc3RzdWl0ZT48dGVzdGNhc2UgZmlsZT0ic3JjL2FwcC5weSIgbGluZT0iMTIiIG5hbWU9InRlc3RfaW52YWxpZCI+PGZhaWx1cmUvPjwvdGVzdGNhc2U+PC90ZXN0c3VpdGU+","digest":"sha256:fbc111bcc1e072a64b0ad705a5044dfc413da6abf39c8c5e4b93d9ae04f6fb71"},"status":"COMPLETE","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"version":"8-profile-1"}]} \ No newline at end of file diff --git a/examples/work/bundle/inventory.json b/examples/work/bundle/inventory.json new file mode 100644 index 0000000..e591d68 --- /dev/null +++ b/examples/work/bundle/inventory.json @@ -0,0 +1 @@ +{"changes":{"items":[{"author":"worker","base":"cccccccccccccccccccccccccccccccccccccccc","checks":{"items":[{"at":"2026-10-02T12:00:00Z","id":"c10","name":"tests","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"PASS"}],"reasons":[],"status":"COMPLETE"},"conflict":false,"draft":false,"files":{"items":["src/app.py"],"reasons":[],"status":"COMPLETE"},"head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","id":"10","merge_train":false,"owners":[],"reviewers":[],"reviews":{"items":[{"actor":"reviewer","at":"2026-10-02T12:00:00Z","id":"r10","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"APPROVED"}],"reasons":[],"status":"COMPLETE"},"state":"OPEN","threads":{"items":[],"reasons":[],"status":"COMPLETE"},"title":"Add the feature","updated_at":"2026-10-02T12:00:00Z"},{"author":"other","base":"cccccccccccccccccccccccccccccccccccccccc","checks":{"items":[{"at":"2026-10-02T12:00:00Z","id":"c10","name":"tests","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"PASS"}],"reasons":[],"status":"COMPLETE"},"conflict":false,"draft":false,"files":{"items":["src/app.py"],"reasons":[],"status":"COMPLETE"},"head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","id":"11","merge_train":false,"owners":[],"reviewers":[],"reviews":{"items":[],"reasons":[],"status":"COMPLETE"},"state":"OPEN","threads":{"items":[],"reasons":[],"status":"COMPLETE"},"title":"Review the parser","updated_at":"2026-10-02T12:00:00Z"}],"reasons":[],"status":"COMPLETE"},"contract":"devostasis.work.v1","issues":{"items":[{"id":"20","owners":["worker"],"state":"OPEN","title":"Add validation","updated_at":"2026-10-02T12:00:00Z"}],"reasons":[],"status":"COMPLETE"},"kind":"inventory","observed_at":"2026-10-02T12:00:00Z","receipts":[],"selection":{"changes":"OPEN_AND_REGISTERED","issues":["20"]},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}} \ No newline at end of file diff --git a/examples/work/bundle/manifest.json b/examples/work/bundle/manifest.json new file mode 100644 index 0000000..7cdd536 --- /dev/null +++ b/examples/work/bundle/manifest.json @@ -0,0 +1 @@ +{"bundle_id":"sha256:5333c42f402d006db1514d8b9269edf36bf0eea03892dd52a3771ae0802bf5bd","contract":"devostasis.work.v1","engine":"devostasis.work-engine.v1","kind":"bundle","members":{"evidence.json":"sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672","inventory.json":"sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","policy.json":"sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2","report.md":"sha256:bb2812581ce80544219d0eb6faa77fa858b30203c81a6be97d0bbeeb8fd509eb","scope.json":"sha256:11d17f4baf7bf952a74b68922e30d9d1905e1cadf0a96bd872699eebcdba4c95"},"vitals_anchor":null} \ No newline at end of file diff --git a/examples/work/bundle/policy.json b/examples/work/bundle/policy.json new file mode 100644 index 0000000..cf9198b --- /dev/null +++ b/examples/work/bundle/policy.json @@ -0,0 +1 @@ +{"actor":"worker","allow_merge_train":false,"contract":"devostasis.work.v1","criteria":[{"acceptance":["Reject an invalid input and accept a valid input."],"dependencies":[],"done":false,"id":"validation","implementations":[],"issue":"20","paths":["src/app.py"],"symbols":["validate"]}],"dispositions":[],"kind":"policy","minimum_approvals":1,"priorities":{"issue:20":{"rank":0,"rationale":"Explicit customer acceptance."}},"queue_order":["review","finish_merge","implement_issue","research","analyze_code"],"read_budget":{"max_bytes":200000,"max_files":20},"requests":[{"acceptance":["Record a bounded reproducer and a rule verdict."],"dependencies":[],"id":"parser-question","owner":null,"paths":["src/parser.py"],"question":"Which parser rule explains the recorded contradiction?","queue":"research","symbols":["parse"]}],"required_checks":["tests"],"roles":["all"],"ttl_seconds":3600,"version":"widget-1"} \ No newline at end of file diff --git a/examples/work/bundle/report.md b/examples/work/bundle/report.md new file mode 100644 index 0000000..91e65db --- /dev/null +++ b/examples/work/bundle/report.md @@ -0,0 +1,17 @@ +# Devostasis work scope + +Observed: 2026-10-02T12:00:00Z · expires: 2026-10-02T13:00:00Z + +Read-only proposal. Recheck source state and consumer authorization before execution. + +| Queue | Eligibility | Source | Action | Reasons | +| --- | --- | --- | --- | --- | +| implement_issue | READY | issue:20 | implement_issue | | +| finish_merge | READY | change:10 | merge | | +| review | READY | change:11 | review | | +| research | READY | request:parser-question | research | | +| analyze_code | READY | sha256:72b814d58402f4b5e53fab7af673e1d2a735d53232ea870adf97e0c8b5e81ef5 | analyze_code | | +| finish_merge | BLOCKED | change:11 | rework | FOREIGN_IMPLEMENTATION_OWNER | + +Coverage: changes=COMPLETE, issues=COMPLETE +Excluded: 1; evidence gaps: 0. diff --git a/examples/work/bundle/scope.json b/examples/work/bundle/scope.json new file mode 100644 index 0000000..065f02a --- /dev/null +++ b/examples/work/bundle/scope.json @@ -0,0 +1 @@ +{"authority":"Read-only proposal. Recheck source state and consumer authorization before execution.","contract":"devostasis.work.v1","coverage":{"changes":"COMPLETE","issues":"COMPLETE"},"excluded":[{"reason":"AUTHOR_CANNOT_INDEPENDENTLY_REVIEW","source":"change:10"}],"expires_at":"2026-10-02T13:00:00Z","gaps":[],"inputs":{"evidence":"sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672","inventory":"sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","policy":"sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2"},"items":[{"acceptance":["Reject an invalid input and accept a valid input."],"action":"implement_issue","continuation":true,"criterion":"validation","dependencies":[],"eligibility":"READY","evidence":["sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2","sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672"],"executor":"worker","fingerprint":"sha256:eb1436cb7b598c5e31784df760fe4aaf05be60b89efb4b1fb62243f0eee1936c","id":"sha256:939096a134dafc3bad31f2fd780c7a8097923a4730d842d5874d5f5a8f475870","implementation_owner":"worker","merge_gate":null,"priority":{"rank":0,"rationale":"Explicit customer acceptance."},"queue":"implement_issue","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/app.py"],"symbols":["validate"],"total_paths":1},"reasons":[],"revision":{"content":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","issue":"sha256:ccfa6af76afa4585b4c323fe0a14e8ff12248a466e9b046bd68488116de59f99"},"source":"issue:20","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"title":"Add validation"},{"acceptance":["Resolve the listed blockers and recheck this exact head before any authorized merge."],"action":"merge","continuation":true,"criterion":null,"dependencies":[],"eligibility":"READY","evidence":["sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2","sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672"],"executor":"worker","fingerprint":"sha256:2bc320846e006cbae7acd12fa3de5401bf35bc4937b8c3a43100b38f6f97c77e","id":"sha256:831f8d5b289ae3912bcc93aa71139fa9df7392fd6d6b817c3ad8a06c7be6ed2f","implementation_owner":"worker","merge_gate":{"reasons":[],"status":"READY"},"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"finish_merge","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/app.py"],"symbols":[],"total_paths":1},"reasons":[],"revision":{"base":"cccccccccccccccccccccccccccccccccccccccc","head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"source":"change:10","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"title":"Add the feature"},{"acceptance":["Record an independent verdict on this exact head and scope."],"action":"review","continuation":false,"criterion":null,"dependencies":[],"eligibility":"READY","evidence":["sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2","sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672"],"executor":"worker","fingerprint":"sha256:373572fac32b17d72a203a57c54ca74a5158ea76a02d3e4fe60aa509240b1d32","id":"sha256:cea5d7476fdb1291a2f5ef31bc5e706778eb6efb538b85cc5b5d963d19dd9a29","implementation_owner":null,"merge_gate":null,"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"review","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/app.py"],"symbols":[],"total_paths":1},"reasons":[],"revision":{"base":"cccccccccccccccccccccccccccccccccccccccc","head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"source":"change:11","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"title":"Review the parser"},{"acceptance":["Record a bounded reproducer and a rule verdict."],"action":"research","continuation":false,"criterion":"parser-question","dependencies":[],"eligibility":"READY","evidence":["sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2","sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672"],"executor":"worker","fingerprint":"sha256:3401ae47484af8156fcec4bef8a8d31efc5725309ddebf01c727bd51e30c40b1","id":"sha256:90e01a30cfbd76653abdda9110e68cec8bb281e32581d4db62f27731a7a6f1a4","implementation_owner":null,"merge_gate":null,"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"research","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/parser.py"],"symbols":["parse"],"total_paths":1},"reasons":[],"revision":{"content":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"source":"request:parser-question","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"title":"Which parser rule explains the recorded contradiction?"},{"acceptance":["Reproduce this exact test at the recorded revision and verify its expected behavior."],"action":"analyze_code","continuation":false,"criterion":"test-failure","dependencies":[],"eligibility":"READY","evidence":["sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2","sha256:fbc111bcc1e072a64b0ad705a5044dfc413da6abf39c8c5e4b93d9ae04f6fb71","sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672"],"executor":"worker","fingerprint":"sha256:4ede0045b25ddbebd6ba0bafdf053513bdbecd120ffd5a508f6f202ba219bc92","id":"sha256:1bbbb2fff8d71f3ba3b60dd8a930d7de40d0bc7133e74f8dd7b78535e966ea63","implementation_owner":null,"merge_gate":null,"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"analyze_code","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/app.py"],"symbols":["test_invalid"],"total_paths":1},"reasons":[],"revision":{"content":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"source":"sha256:72b814d58402f4b5e53fab7af673e1d2a735d53232ea870adf97e0c8b5e81ef5","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"title":"A test failure, error or explicit flaky rerun was reported."},{"acceptance":["Resolve the listed blockers and recheck this exact head before any authorized merge."],"action":"rework","continuation":false,"criterion":null,"dependencies":[],"eligibility":"BLOCKED","evidence":["sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2","sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672"],"executor":"worker","fingerprint":"sha256:a698103ade12c1fa81dcf95b04e33f45c334170370736d1cf74f6cc181edc070","id":"sha256:747302f672eb5452b56f574af8513024297d77544b8499ec26d5aa2440cda4cd","implementation_owner":"other","merge_gate":{"reasons":["EXACT_HEAD_APPROVAL_REQUIRED"],"status":"BLOCKED"},"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"finish_merge","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/app.py"],"symbols":[],"total_paths":1},"reasons":["FOREIGN_IMPLEMENTATION_OWNER"],"revision":{"base":"cccccccccccccccccccccccccccccccccccccccc","head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"source":"change:11","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"title":"Review the parser"}],"kind":"scope","observed_at":"2026-10-02T12:00:00Z","planned_at":"2026-10-02T12:00:00Z","policy_version":"widget-1","queues":{"analyze_code":["sha256:1bbbb2fff8d71f3ba3b60dd8a930d7de40d0bc7133e74f8dd7b78535e966ea63"],"finish_merge":["sha256:831f8d5b289ae3912bcc93aa71139fa9df7392fd6d6b817c3ad8a06c7be6ed2f","sha256:747302f672eb5452b56f574af8513024297d77544b8499ec26d5aa2440cda4cd"],"implement_issue":["sha256:939096a134dafc3bad31f2fd780c7a8097923a4730d842d5874d5f5a8f475870"],"research":["sha256:90e01a30cfbd76653abdda9110e68cec8bb281e32581d4db62f27731a7a6f1a4"],"review":["sha256:cea5d7476fdb1291a2f5ef31bc5e706778eb6efb538b85cc5b5d963d19dd9a29"]},"selection":{"changes":"OPEN_AND_REGISTERED","issues":["20"]},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}} \ No newline at end of file diff --git a/examples/work/evidence.json b/examples/work/evidence.json new file mode 100644 index 0000000..8ddf48b --- /dev/null +++ b/examples/work/evidence.json @@ -0,0 +1 @@ +{"contract":"devostasis.work.v1","kind":"evidence","sources":[{"observed_at":"2026-10-02T12:00:00Z","producer":"pytest","profile":"junit.v1","reason":null,"report":{"base64":"PHRlc3RzdWl0ZT48dGVzdGNhc2UgZmlsZT0ic3JjL2FwcC5weSIgbGluZT0iMTIiIG5hbWU9InRlc3RfaW52YWxpZCI+PGZhaWx1cmUvPjwvdGVzdGNhc2U+PC90ZXN0c3VpdGU+","digest":"sha256:fbc111bcc1e072a64b0ad705a5044dfc413da6abf39c8c5e4b93d9ae04f6fb71"},"status":"COMPLETE","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"version":"8-profile-1"}]} \ No newline at end of file diff --git a/examples/work/github-observe.yml b/examples/work/github-observe.yml new file mode 100644 index 0000000..88f5744 --- /dev/null +++ b/examples/work/github-observe.yml @@ -0,0 +1,22 @@ +# Copy into an adopter's .github/workflows directory after v0.3.0 is released. +name: Work scope +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "17 */6 * * *" + workflow_dispatch: +permissions: + contents: read + issues: read + pull-requests: read + checks: read +jobs: + observe: + uses: drevendev/Devostasis/.github/workflows/work-observe.yml@v0.3.0 + with: + policy-path: .devostasis/work-policy.json +# This observer is deliberately not a required merge check. Artifacts are +# immutable run snapshots with explicit expiry, not a durable canonical store. +# For durable history use the separate protected history job documented below. diff --git a/examples/work/gitlab-observe.yml b/examples/work/gitlab-observe.yml new file mode 100644 index 0000000..b55a700 --- /dev/null +++ b/examples/work/gitlab-observe.yml @@ -0,0 +1,64 @@ +# Include this template and commit an adopter-owned .devostasis/work-policy.json. +# The runner has a mounted private durable volume at WORK_HISTORY_ROOT. Only +# protected canonical jobs can write it. It must not be inside the observed repo. +variables: + DEVOSTASIS_REF: "v0.3.0" + WORK_POLICY: ".devostasis/work-policy.json" + +.work-observer: + image: python:3.12-slim + stage: .post + needs: [] + allow_failure: true + before_script: + - apt-get update -qq && apt-get install -y -qq git >/dev/null + - python -m pip install "git+https://github.com/drevendev/Devostasis.git@${DEVOSTASIS_REF}" + script: + - mkdir -p work-output + - | + context=CANDIDATE + store=work-output/history + if [ "$CI_COMMIT_BRANCH" = "$CI_DEFAULT_BRANCH" ] && [ "$CI_COMMIT_REF_PROTECTED" = "true" ]; then + context=CANONICAL + test -n "$WORK_HISTORY_ROOT" + store="$WORK_HISTORY_ROOT" + fi + python -m devostasis work run --provider gitlab --endpoint "$CI_API_V4_URL" \ + --repo "$CI_PROJECT_ID" --policy "$WORK_POLICY" --revision "$CI_COMMIT_SHA" \ + --context "$context" --store "$store" --max-requests 100 --max-pages 5 --seconds 120 \ + > work-output/result.json 2> work-output/failure.txt + python - <<'PY' + import json, pathlib, shutil, subprocess, sys + result = json.loads(pathlib.Path('work-output/result.json').read_text()) + bundle = pathlib.Path(result['path']) + subprocess.run([sys.executable, '-m', 'devostasis', 'work', 'verify', '--bundle', str(bundle)], check=True) + shutil.copytree(bundle, 'work-output/bundle') + subprocess.run([sys.executable, '-m', 'devostasis', 'work', 'slice', '--bundle', str(bundle), '--limit', '20', '--output', 'work-output/slice.json'], check=True) + PY + artifacts: + when: always + paths: [work-output/] + expire_in: 30 days + +work-canonical: + extends: .work-observer + resource_group: devostasis-canonical-history + # These runner tags/volume mappings must be configured by the adopter. + tags: [work-history-protected] + rules: + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_REF_PROTECTED == "true"' + when: always + +work-candidate: + extends: .work-observer + tags: [work-observation] + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + when: always +# Configure DEVOSTASIS_GITLAB_TOKEN as a protected masked read_api credential +# on the canonical runner. Never assume CI_JOB_TOKEN can read approvals/issues. +# The candidate runner gets no history volume or protected credential. It can +# collect public data or report explicit missing capabilities; no writer secret. +# Enable a schedule every six hours on the protected default branch. Observation +# is non-gating and has no dependency on the checks it reads. For strict quotas, +# reduce budgets or increase cadence; partial collection remains explicit. diff --git a/examples/work/inventory.json b/examples/work/inventory.json new file mode 100644 index 0000000..e591d68 --- /dev/null +++ b/examples/work/inventory.json @@ -0,0 +1 @@ +{"changes":{"items":[{"author":"worker","base":"cccccccccccccccccccccccccccccccccccccccc","checks":{"items":[{"at":"2026-10-02T12:00:00Z","id":"c10","name":"tests","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"PASS"}],"reasons":[],"status":"COMPLETE"},"conflict":false,"draft":false,"files":{"items":["src/app.py"],"reasons":[],"status":"COMPLETE"},"head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","id":"10","merge_train":false,"owners":[],"reviewers":[],"reviews":{"items":[{"actor":"reviewer","at":"2026-10-02T12:00:00Z","id":"r10","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"APPROVED"}],"reasons":[],"status":"COMPLETE"},"state":"OPEN","threads":{"items":[],"reasons":[],"status":"COMPLETE"},"title":"Add the feature","updated_at":"2026-10-02T12:00:00Z"},{"author":"other","base":"cccccccccccccccccccccccccccccccccccccccc","checks":{"items":[{"at":"2026-10-02T12:00:00Z","id":"c10","name":"tests","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"PASS"}],"reasons":[],"status":"COMPLETE"},"conflict":false,"draft":false,"files":{"items":["src/app.py"],"reasons":[],"status":"COMPLETE"},"head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","id":"11","merge_train":false,"owners":[],"reviewers":[],"reviews":{"items":[],"reasons":[],"status":"COMPLETE"},"state":"OPEN","threads":{"items":[],"reasons":[],"status":"COMPLETE"},"title":"Review the parser","updated_at":"2026-10-02T12:00:00Z"}],"reasons":[],"status":"COMPLETE"},"contract":"devostasis.work.v1","issues":{"items":[{"id":"20","owners":["worker"],"state":"OPEN","title":"Add validation","updated_at":"2026-10-02T12:00:00Z"}],"reasons":[],"status":"COMPLETE"},"kind":"inventory","observed_at":"2026-10-02T12:00:00Z","receipts":[],"selection":{"changes":"OPEN_AND_REGISTERED","issues":["20"]},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}} \ No newline at end of file diff --git a/examples/work/policy.json b/examples/work/policy.json new file mode 100644 index 0000000..cf9198b --- /dev/null +++ b/examples/work/policy.json @@ -0,0 +1 @@ +{"actor":"worker","allow_merge_train":false,"contract":"devostasis.work.v1","criteria":[{"acceptance":["Reject an invalid input and accept a valid input."],"dependencies":[],"done":false,"id":"validation","implementations":[],"issue":"20","paths":["src/app.py"],"symbols":["validate"]}],"dispositions":[],"kind":"policy","minimum_approvals":1,"priorities":{"issue:20":{"rank":0,"rationale":"Explicit customer acceptance."}},"queue_order":["review","finish_merge","implement_issue","research","analyze_code"],"read_budget":{"max_bytes":200000,"max_files":20},"requests":[{"acceptance":["Record a bounded reproducer and a rule verdict."],"dependencies":[],"id":"parser-question","owner":null,"paths":["src/parser.py"],"question":"Which parser rule explains the recorded contradiction?","queue":"research","symbols":["parse"]}],"required_checks":["tests"],"roles":["all"],"ttl_seconds":3600,"version":"widget-1"} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index dee14f8..b6fda53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "devostasis" -version = "0.2.0" +version = "0.3.0" description = "Deterministic, model-free vital signs for software repositories." readme = "README.md" license = "MIT" diff --git a/scripts/make_work_examples.py b/scripts/make_work_examples.py new file mode 100644 index 0000000..ad0825a --- /dev/null +++ b/scripts/make_work_examples.py @@ -0,0 +1,48 @@ +"""Regenerate the public synthetic Evidence to Action example, offline.""" +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) +from devostasis.canonical import canonical_bytes +from devostasis.workscope import CONTRACT +from devostasis.workscope.bundle import build +from devostasis.workscope.collect import complete +from devostasis.workscope.evidence import empty_evidence, source +from devostasis.workscope.model import default_policy + +root = Path(__file__).resolve().parents[1] / "examples" / "work" +root.mkdir(exist_ok=True) +at = "2026-10-02T12:00:00Z" +sha, head, base = "a" * 40, "b" * 40, "c" * 40 +subject = {"provider": "github", "endpoint": "https://api.github.com", "project_id": "123456", + "locator": "acme/widget", "revision": sha, "context": "CANONICAL"} +change = {"id": "10", "title": "Add the feature", "state": "OPEN", "author": "worker", "owners": [], "reviewers": [], + "head": head, "base": base, "updated_at": at, "draft": False, "conflict": False, "merge_train": False, + "reviews": complete([{"id": "r10", "actor": "reviewer", "revision": head, "state": "APPROVED", "at": at}]), + "checks": complete([{"id": "c10", "name": "tests", "revision": head, "state": "PASS", "at": at}]), + "threads": complete(), "files": complete(["src/app.py"])} +other = {**change, "id": "11", "author": "other", "title": "Review the parser", "reviews": complete()} +inv = {"contract": CONTRACT, "kind": "inventory", "subject": subject, "observed_at": at, + "changes": complete([change, other]), "issues": complete([{"id": "20", "title": "Add validation", + "state": "OPEN", "owners": ["worker"], "updated_at": at}]), "receipts": [], + "selection": {"changes": "OPEN_AND_REGISTERED", "issues": ["20"]}} +policy = default_policy("worker") +policy["version"] = "widget-1" +policy["required_checks"] = ["tests"] +policy["priorities"] = {"issue:20": {"rank": 0, "rationale": "Explicit customer acceptance."}} +policy["criteria"] = [{"id": "validation", "issue": "20", "acceptance": ["Reject an invalid input and accept a valid input."], + "paths": ["src/app.py"], "symbols": ["validate"], "dependencies": [], "implementations": [], "done": False}] +policy["requests"] = [{"id": "parser-question", "queue": "research", "question": "Which parser rule explains the recorded contradiction?", + "paths": ["src/parser.py"], "symbols": ["parse"], "acceptance": ["Record a bounded reproducer and a rule verdict."], + "dependencies": [], "owner": None}] +evidence = empty_evidence() +junit = b'' +evidence["sources"].append(source("junit.v1", "pytest", "8-profile-1", subject, at, junit)) +for name, value in (("inventory", inv), ("policy", policy), ("evidence", evidence)): + (root / f"{name}.json").write_bytes(canonical_bytes(value)) +_, content = build(inv, policy, evidence) +destination = root / "bundle" +destination.mkdir(exist_ok=True) +for name, value in content.items(): + (destination / name).write_bytes(value) +print(destination) diff --git a/src/devostasis/__init__.py b/src/devostasis/__init__.py index 92d65bc..7f49110 100644 --- a/src/devostasis/__init__.py +++ b/src/devostasis/__init__.py @@ -1,3 +1,3 @@ """Devostasis: deterministic, model-free vital signs for software repositories.""" -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/src/devostasis/cli.py b/src/devostasis/cli.py index 2888904..907ee70 100644 --- a/src/devostasis/cli.py +++ b/src/devostasis/cli.py @@ -500,6 +500,8 @@ def build_parser() -> argparse.ArgumentParser: summary_p = sub.add_parser("actions-summary", help="write a GitHub Actions job summary and step outputs (attention, levels, bands, gauges) for a bundle") summary_p.add_argument("--bundle", required=True, help="bundle directory") summary_p.set_defaults(func=cmd_actions_summary) + from .workscope.cli import add_parser + add_parser(sub) return parser diff --git a/src/devostasis/workscope/__init__.py b/src/devostasis/workscope/__init__.py new file mode 100644 index 0000000..27b4339 --- /dev/null +++ b/src/devostasis/workscope/__init__.py @@ -0,0 +1,3 @@ +"""Versioned, read-only Evidence to Action companion. No eighth Vital.""" + +CONTRACT = "devostasis.work.v1" diff --git a/src/devostasis/workscope/bundle.py b/src/devostasis/workscope/bundle.py new file mode 100644 index 0000000..09c4a8a --- /dev/null +++ b/src/devostasis/workscope/bundle.py @@ -0,0 +1,209 @@ +"""Self-contained replayable bundles and atomic append-only publication.""" + +from __future__ import annotations + +import base64 +import contextlib +import os +import re +from pathlib import Path +import shutil +import tempfile + +from ..canonical import canonical_bytes, digest, digest_bytes, loads +from . import CONTRACT +from .model import ROLES, ScopeError, fields, identity, instant, number, require +from .planner import project + +MEMBERS = ("inventory.json", "policy.json", "evidence.json", "scope.json", "report.md") +MAX_MEMBER_BYTES = 300 * 1024 * 1024 + + +def escape(value): + return str(value).replace("&", "&").replace("<", "<").replace(">", ">").replace("|", "\\|").replace("\n", " ").replace("\r", " ").replace("`", "`") + + +def render(scope): + rows = ["# Devostasis work scope", "", f"Observed: {scope['observed_at']} · expires: {scope['expires_at']}", + "", escape(scope["authority"]), "", "| Queue | Eligibility | Source | Action | Reasons |", "| --- | --- | --- | --- | --- |"] + for item in scope["items"]: + rows.append("| " + " | ".join(escape(v) for v in (item["queue"], item["eligibility"], item["source"], + item["action"], ", ".join(item["reasons"]))) + " |") + rows += ["", "Coverage: " + ", ".join(f"{k}={v}" for k, v in scope["coverage"].items()), + f"Excluded: {len(scope['excluded'])}; evidence gaps: {len(scope['gaps'])}.", ""] + return "\n".join(rows).encode("utf-8") + + +def build(inv, config, evidence, at=None, anchor=None): + scope = project(inv, config, evidence, at) + if anchor is not None: + fields(anchor, ("bundle_id", "manifest_digest")) + require(isinstance(anchor["bundle_id"], str) and re.fullmatch(r"[0-9a-f]{64}", anchor["bundle_id"]) and + isinstance(anchor["manifest_digest"], str) and re.fullmatch(r"sha256:[0-9a-f]{64}", anchor["manifest_digest"]), "invalid Vitals anchor") + content = {name: canonical_bytes(value) for name, value in zip(MEMBERS, (inv, config, evidence, scope))} + content["report.md"] = render(scope) + preimage = {"contract": CONTRACT, "kind": "bundle", "engine": "devostasis.work-engine.v1", + "members": {k: digest_bytes(v) for k, v in content.items()}, "vitals_anchor": anchor} + manifest = {**preimage, "bundle_id": digest(preimage)} + content["manifest.json"] = canonical_bytes(manifest) + return manifest, content + + +def read(path): + root = Path(path) + require(root.is_dir() and not root.is_symlink(), "bundle directory unavailable or linked") + require({p.name for p in root.iterdir()} == set(MEMBERS) | {"manifest.json"}, "bundle has missing or extra members") + output = {} + for name in (*MEMBERS, "manifest.json"): + member = root / name + require(member.is_file() and not member.is_symlink() and member.stat().st_size <= MAX_MEMBER_BYTES, + "bundle member invalid or exceeds byte budget") + output[name] = member.read_bytes() + return output + + +def verify_content(content): + require(set(content) == set(MEMBERS) | {"manifest.json"}, "bundle member set mismatch") + decoded = {k: loads(v.decode("utf-8")) for k, v in content.items() if k.endswith(".json")} + for name, value in decoded.items(): + require(content[name] == canonical_bytes(value), "bundle member is not canonical") + m = decoded["manifest.json"] + fields(m, ("contract", "kind", "engine", "members", "vitals_anchor", "bundle_id")) + require(m["contract"] == CONTRACT and m["kind"] == "bundle" and m["engine"] == "devostasis.work-engine.v1", + "unsupported bundle contract or replay engine") + require(set(m["members"]) == set(MEMBERS), "manifest member set mismatch") + require(all(digest_bytes(content[k]) == m["members"][k] for k in MEMBERS), "bundle member digest mismatch") + expected, replay = build(decoded["inventory.json"], decoded["policy.json"], decoded["evidence.json"], + decoded["scope.json"]["planned_at"], m["vitals_anchor"]) + require(expected == m and replay == content, "bundle does not replay from its admitted inputs") + return decoded + + +def verify(path): + return verify_content(read(path)) + + +@contextlib.contextmanager +def locked(path): + # OS locks release after process death; a leftover lock file is harmless. + handle = open(path, "a+b") + if os.name == "nt": + import msvcrt + if handle.seek(0, 2) == 0: + handle.write(b"0"); handle.flush() + handle.seek(0) + try: + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + except OSError as exc: + handle.close(); raise ScopeError("scope store is locked by another publisher") from exc + unlock = lambda: msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as exc: + handle.close(); raise ScopeError("scope store is locked by another publisher") from exc + unlock = lambda: fcntl.flock(handle, fcntl.LOCK_UN) + try: + yield + finally: + handle.seek(0); unlock(); handle.close() + + +def atomic(path, data): + fd, name = tempfile.mkstemp(prefix=".pending-", dir=path.parent) + try: + with os.fdopen(fd, "wb") as stream: + stream.write(data); stream.flush(); os.fsync(stream.fileno()) + os.replace(name, path) + finally: + if os.path.exists(name): + os.unlink(name) + + +def publish(store, content): + decoded = verify_content(content) + scope = decoded["scope.json"] + bundle_id = decoded["manifest.json"]["bundle_id"] + key = digest(identity(scope["subject"]))[7:] + root = Path(store) / "work" / key + root.mkdir(parents=True, exist_ok=True) + require(not root.is_symlink() and not root.parent.is_symlink(), "linked store project directory") + dest = root / "bundles" / bundle_id[7:] + with locked(root / ".lock"): + dest.parent.mkdir(exist_ok=True) + require(not dest.parent.is_symlink(), "linked immutable bundle directory") + if dest.exists(): + require(read(dest) == content, "immutable bundle collision") + else: + stage = Path(tempfile.mkdtemp(prefix=".pending-", dir=dest.parent)) + try: + for name, data in content.items(): + with open(stage / name, "wb") as stream: + stream.write(data); stream.flush(); os.fsync(stream.fileno()) + verify(stage) + os.rename(stage, dest) + finally: + if stage.exists(): + require(stage.resolve().parent == dest.parent.resolve() and not stage.is_symlink(), "unsafe staging cleanup path") + shutil.rmtree(stage) + # Candidate snapshots are preserved, but never become canonical latest. + if scope["subject"]["context"] == "CANONICAL": + pointer = root / "latest.json" + if pointer.exists(): + old = latest(root) + promote = instant(scope["observed_at"]) > instant(old["scope.json"]["observed_at"]) + if instant(scope["observed_at"]) == instant(old["scope.json"]["observed_at"]): + require(bundle_id == old["manifest.json"]["bundle_id"], "same-time canonical publication collision") + else: + promote = True + if promote: + atomic(pointer, canonical_bytes({"contract": CONTRACT, "bundle_id": bundle_id, + "manifest_digest": digest_bytes(content["manifest.json"]), "project": identity(scope["subject"])})) + return dest + + +def latest(root): + pointer = Path(root) / "latest.json" + require(pointer.is_file() and not pointer.is_symlink() and pointer.stat().st_size < 8192, "latest pointer unavailable") + data = loads(pointer.read_text(encoding="utf-8")) + fields(data, ("contract", "bundle_id", "manifest_digest", "project")) + require(data["contract"] == CONTRACT and isinstance(data["bundle_id"], str) and + len(data["bundle_id"]) == 71 and all(c in "0123456789abcdef" for c in data["bundle_id"][7:]) + and data["bundle_id"].startswith("sha256:"), "invalid latest pointer") + dest = Path(root) / "bundles" / data["bundle_id"][7:] + content = read(dest); decoded = verify_content(content) + require(digest_bytes(content["manifest.json"]) == data["manifest_digest"] and + decoded["manifest.json"]["bundle_id"] == data["bundle_id"] and + identity(decoded["scope.json"]["subject"]) == data["project"] and + decoded["scope.json"]["subject"]["context"] == "CANONICAL", "latest pointer binding mismatch") + return decoded + + +def slice_scope(scope, role="all", limit=20, cursor=None, at=None): + require(role in ROLES, "unknown consumer role"); number(limit, 1, 1000) + binding = {"scope": digest(scope), "role": role} + offset = 0 + if cursor: + require(isinstance(cursor, str) and len(cursor) < 4096, "invalid cursor") + try: + raw = base64.urlsafe_b64decode(cursor.encode("ascii")) + token = loads(raw.decode("utf-8")) + except (ValueError, UnicodeError) as exc: + raise ScopeError("invalid cursor") from exc + fields(token, ("scope", "role", "offset")) + require({k: token[k] for k in binding} == binding and canonical_bytes(token) == raw, + "cursor belongs to another scope or role") + offset = number(token["offset"], 0, 100000) + if at is not None and instant(at) > instant(scope["expires_at"]): + return {**binding, "status": "EXPIRED", "items": [{"id": digest({"recovery": identity(scope["subject"])}), + "queue": "research", "action": "recover_evidence", "source": "inventory", + "acceptance": ["Collect, rebuild and verify a fresh bounded scope before selecting work."]}], + "cursor": None, "overflow": 0, "total": 1, "excluded": scope["excluded"]} + items = [i for i in scope["items"] if i["queue"] in ROLES[role]] + require(offset <= len(items), "cursor offset exceeds scope") + end = min(offset + limit, len(items)) + token = base64.urlsafe_b64encode(canonical_bytes({**binding, "offset": end})).decode("ascii") if end < len(items) else None + return {**binding, "status": "INCOMPLETE" if scope["gaps"] else "EMPTY" if not items else "AVAILABLE", + "items": items[offset:end], "cursor": token, "overflow": len(items) - end, + "total": len(items), "excluded": scope["excluded"], "coverage": scope["coverage"]} diff --git a/src/devostasis/workscope/cli.py b/src/devostasis/workscope/cli.py new file mode 100644 index 0000000..b32dd5a --- /dev/null +++ b/src/devostasis/workscope/cli.py @@ -0,0 +1,169 @@ +"""Consumer commands; network commands never claim or execute tasks.""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import os +from pathlib import Path +import sys + +from ..canonical import canonical_bytes, digest, digest_bytes, loads +from .bundle import build, latest, publish, slice_scope, verify +from .collect import Client, collect +from .evidence import empty_evidence, source, validate +from .model import ScopeError, default_policy, identity, instant, require +from .planner import project + + +def read_json(path): + member = Path(path) + require(member.is_file() and member.stat().st_size <= 300 * 1024 * 1024, "input missing or too large") + return loads(member.read_text(encoding="utf-8")) + + +def emit(value, output=None): + data = canonical_bytes(value) + if output: + Path(output).parent.mkdir(parents=True, exist_ok=True) + Path(output).write_bytes(data) + else: + print(data.decode("utf-8")) + + +def clock(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def client(args, provider=None, endpoint=None): + provider = provider or args.provider + endpoint = endpoint or args.endpoint or ("https://api.github.com" if provider == "github" else "https://gitlab.com/api/v4") + token = os.getenv(args.token_env or ("DEVOSTASIS_GITHUB_TOKEN" if provider == "github" else "DEVOSTASIS_GITLAB_TOKEN")) + if provider == "github" and not token: + from ..cli import resolve_token + token, _ = resolve_token(None) + return Client(provider, endpoint, token, max_requests=args.max_requests, max_pages=args.max_pages, + seconds=args.seconds, cache=args.cache) + + +def recheck(decoded, current, at, *, actor, policy_version, expected_project): + scope, config = decoded["scope.json"], decoded["policy.json"] + require(identity(scope["subject"]) == expected_project, "consumer expected project mismatch") + require(actor == config["actor"] and policy_version == config["version"], "consumer actor or policy version mismatch") + require(instant(at) <= instant(scope["expires_at"]), "scope expired; recover evidence first") + require(instant(at) >= instant(current["observed_at"]), "recheck clock predates refreshed inventory") + require(identity(current["subject"]) == identity(scope["subject"]), "recheck project mismatch") + require(current["subject"]["context"] == scope["subject"]["context"], "recheck context mismatch") + + +def cmd(args): + try: + if args.operation == "policy": + emit(default_policy(args.actor), args.output); return 0 + if args.operation == "import": + evidence = read_json(args.evidence) if args.evidence else empty_evidence() + inv = read_json(args.inventory) + evidence["sources"].append(source(args.profile, args.producer, args.producer_version, + inv["subject"], args.at or inv["observed_at"], Path(args.report).read_bytes())) + validate(evidence); emit(evidence, args.output); return 0 + if args.operation in ("collect", "run"): + inv = collect(client(args), args.repo, read_json(args.policy), args.at, + revision=args.revision, context=args.context) + if args.operation == "collect": + emit(inv, args.output); return 0 + if args.operation in ("build", "run"): + inv = inv if args.operation == "run" else read_json(args.inventory) + config = read_json(args.policy) + evidence = read_json(args.evidence) if args.evidence else empty_evidence() + anchor = None + if args.vitals_bundle: + from ..bundle import verify_dir + require(not verify_dir(Path(args.vitals_bundle)), "Vitals bundle did not verify") + manifest_path = Path(args.vitals_bundle) / "manifest.json" + manifest = read_json(manifest_path) + anchor = {"bundle_id": manifest["bundle_id"], "manifest_digest": digest_bytes(manifest_path.read_bytes())} + manifest, content = build(inv, config, evidence, args.at, anchor) + dest = publish(args.store, content) + emit({"bundle_id": manifest["bundle_id"], "path": str(dest.resolve())}); return 0 + decoded = latest(args.latest) if args.latest else verify(args.bundle) + scope = decoded["scope.json"] + if args.operation in ("verify", "replay"): + emit({"status": "VERIFIED", "bundle_id": decoded["manifest.json"]["bundle_id"]}); return 0 + if args.operation == "slice": + emit(slice_scope(scope, args.role, args.limit, args.cursor, args.at or clock()), args.output); return 0 + item = next((i for i in scope["items"] if i["id"] == args.item), None) + require(item is not None, "item not in verified scope") + if args.operation == "explain": + emit(item, args.output); return 0 + now = args.at or clock() + require(digest(read_json(args.policy)) == scope["inputs"]["policy"], "consumer policy digest mismatch") + require(args.actor == decoded["policy.json"]["actor"] and args.policy_version == decoded["policy.json"]["version"], + "consumer actor or policy version mismatch") + require(instant(now) <= instant(scope["expires_at"]), "scope expired; recover evidence first") + expected = {"provider": args.provider, "endpoint": args.endpoint or + ("https://api.github.com" if args.provider == "github" else "https://gitlab.com/api/v4"), + "project_id": args.expected_project_id} + require(identity(scope["subject"]) == expected, "consumer expected project mismatch") + current = read_json(args.inventory) if args.inventory else collect( + client(args, scope["subject"]["provider"], scope["subject"]["endpoint"]), scope["subject"]["locator"], + decoded["policy.json"], args.at, context=scope["subject"]["context"], + revision=scope["subject"]["revision"] if scope["subject"]["context"] == "CANDIDATE" else None, selected=item) + now = args.at or clock() + recheck(decoded, current, now, actor=args.actor, policy_version=args.policy_version, expected_project=expected) + refreshed = project(current, decoded["policy.json"], decoded["evidence.json"], now) + selected = next((i for i in refreshed["items"] if i["id"] == item["id"]), None) + changed = selected is None or selected["revision"] != item["revision"] or selected["implementation_owner"] != item["implementation_owner"] + reasons = ["ITEM_REVISION_OR_OWNER_CHANGED"] if changed else selected["reasons"] + ready = not changed and selected["eligibility"] == "READY" and selected["action"] == item["action"] + if not changed and selected["action"] != item["action"]: + reasons = ["ACTION_CHANGED_REBUILD_SCOPE"] + emit({"item": item["id"], "status": "RECHECKED" if ready else "INVALIDATED", "reasons": reasons, + "current": selected, "authority": scope["authority"]}, args.output) + return 0 if ready else 3 + except (ValueError, KeyError, TypeError, OSError) as exc: + print(f"work: {type(exc).__name__}: {exc}", file=sys.stderr) + return 2 + + +def add_parser(parser): + work = parser.add_parser("work", help="Evidence to Action: bounded read-only work scopes") + operations = work.add_subparsers(dest="operation", required=True) + for operation in ("policy", "import", "collect", "build", "run", "verify", "replay", "slice", "explain", "recheck"): + p = operations.add_parser(operation) + p.set_defaults(func=cmd) + if operation == "policy": + p.add_argument("--actor", required=True) + if operation == "import": + p.add_argument("--report", required=True); p.add_argument("--profile", required=True, choices=("sarif.v1", "junit.v1", "cobertura.v1", "performance.v1")) + p.add_argument("--producer", required=True); p.add_argument("--producer-version", required=True) + if operation in ("policy", "import", "collect", "slice", "explain", "recheck"): + p.add_argument("--output") + if operation in ("import", "build", "recheck"): + p.add_argument("--inventory", required=operation != "recheck") + if operation in ("import", "build", "run"): + p.add_argument("--evidence") + if operation in ("collect", "build", "run", "recheck"): + p.add_argument("--policy", required=True) + if operation in ("build", "run"): + p.add_argument("--store", required=True); p.add_argument("--vitals-bundle") + if operation in ("collect", "run", "recheck"): + p.add_argument("--provider", choices=("github", "gitlab"), default="github") + p.add_argument("--endpoint"); p.add_argument("--token-env"); p.add_argument("--cache") + p.add_argument("--max-requests", type=int, default=100); p.add_argument("--max-pages", type=int, default=10) + p.add_argument("--seconds", type=int, default=120) + if operation in ("collect", "run"): + p.add_argument("--repo", required=True); p.add_argument("--revision") + p.add_argument("--context", choices=("CANONICAL", "CANDIDATE"), default="CANONICAL") + if operation in ("verify", "replay", "slice", "explain", "recheck"): + loc = p.add_mutually_exclusive_group(required=True) + loc.add_argument("--bundle"); loc.add_argument("--latest") + if operation == "slice": + p.add_argument("--role", choices=("all", "reviewer", "developer", "researcher", "analyst"), default="all") + p.add_argument("--limit", type=int, default=20); p.add_argument("--cursor") + if operation in ("explain", "recheck"): + p.add_argument("--item", required=True) + if operation == "recheck": + p.add_argument("--actor", required=True); p.add_argument("--policy-version", required=True) + p.add_argument("--expected-project-id", required=True) + if operation in ("import", "collect", "build", "run", "slice", "recheck"): + p.add_argument("--at", help="explicit RFC3339 clock for fixtures/offline replay") diff --git a/src/devostasis/workscope/collect.py b/src/devostasis/workscope/collect.py new file mode 100644 index 0000000..a81c10b --- /dev/null +++ b/src/devostasis/workscope/collect.py @@ -0,0 +1,376 @@ +"""Bounded GitHub/GitLab read adapters with explicit capability receipts.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from http.client import HTTPException +import hashlib +import re +from pathlib import Path +import time +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlencode, urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener + +from ..canonical import canonical_bytes, digest, loads +from . import CONTRACT +from .bundle import atomic +from .model import ScopeError, identity, inventory, path, policy, require, subject, timestamp + +MAX_RESPONSE = 4 * 1024 * 1024 + + +def complete(items=()): + return {"status": "COMPLETE", "items": list(items), "reasons": []} + + +def unavailable(reason): + return {"status": "UNAVAILABLE", "items": [], "reasons": [reason]} + + +class SafeRedirect(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + require(urlsplit(req.full_url).scheme == urlsplit(newurl).scheme == "https" and + urlsplit(req.full_url).netloc == urlsplit(newurl).netloc, + "cross-origin or downgraded redirect refused") + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +class HTTPTransport: + def __init__(self): + self.opener = build_opener(SafeRedirect()) + + def request(self, method, url, headers, body, timeout): + # GraphQL queries are the sole permitted POST, generated by this module. + query = loads(body.decode("utf-8")).get("query", "") if body else "" + require(method == "GET" or method == "POST" and urlsplit(url).path in ("/graphql", "/api/graphql") and + isinstance(query, str) and query.startswith("query(") and not re.search(r"\bmutation\b", query), + "adapter permits read operations only") + deadline = time.monotonic() + timeout + try: + response = self.opener.open(Request(url, data=body, headers=headers, method=method), timeout=timeout) + except HTTPError as exc: + response = exc + except (URLError, TimeoutError, OSError) as exc: + raise ScopeError("NETWORK_UNAVAILABLE") from exc + with response: + chunks, size = [], 0 + # read1 returns available bytes, unlike read(n) which can keep + # accumulating a slow response while each socket timeout resets. + while True: + require(time.monotonic() < deadline, "RESPONSE_TIME_CAP") + chunk = response.read1(min(65536, MAX_RESPONSE + 1 - size)) + if not chunk: + break + chunks.append(chunk); size += len(chunk) + require(size <= MAX_RESPONSE, "RESPONSE_BYTE_CAP") + data = b"".join(chunks) + return response.code, {k.lower(): v for k, v in response.headers.items()}, data + + +class Client: + def __init__(self, provider, endpoint, token=None, *, transport=None, max_requests=100, + max_pages=10, seconds=120, cache=None): + require(provider in ("github", "gitlab"), "unsupported provider") + parsed = urlsplit(endpoint) + require(parsed.scheme == "https" and parsed.hostname and not parsed.username and not parsed.query + and not parsed.fragment and not endpoint.endswith("/"), "invalid HTTPS endpoint") + require(type(max_requests) is int and 1 <= max_requests <= 10000 and type(max_pages) is int and + 1 <= max_pages <= 100 and type(seconds) is int and 1 <= seconds <= 3600, "invalid collection budget") + self.provider, self.endpoint, self.token = provider, endpoint, token + self.transport = transport or HTTPTransport() + self.max_requests, self.max_pages = max_requests, max_pages + self.deadline = time.monotonic() + seconds + self.receipts, self.telemetry, self.requests = [], [], 0 + self.cache = Path(cache) if cache else None + self.partition = digest({"provider": provider, "endpoint": endpoint, + "credential": hashlib.sha256((token or "anonymous").encode()).hexdigest()})[7:] + + def get(self, path, params=None, *, query=None): + require(path.startswith("/") and not path.startswith("//") and ".." not in path, "invalid API path") + require(self.requests < self.max_requests and time.monotonic() < self.deadline, "REQUEST_OR_TIME_CAP") + self.requests += 1 + base = self.endpoint + if path == "/graphql" and self.provider == "github" and base.endswith("/api/v3"): + base = base[:-3] + url = base + path + ("?" + urlencode(params) if params else "") + headers = {"Accept": "application/json", "User-Agent": "Devostasis-work/1"} + if self.provider == "github": + headers["X-GitHub-Api-Version"] = "2022-11-28" + if self.token: + headers["Authorization" if self.provider == "github" else "PRIVATE-TOKEN"] = ( + "Bearer " + self.token if self.provider == "github" else self.token) + cache_path, cached = None, None + if self.cache and query is None: + cache_path = self.cache / self.partition / (digest(url)[7:] + ".json") + try: + if cache_path.is_file() and not cache_path.is_symlink() and cache_path.stat().st_size < MAX_RESPONSE * 2: + entry = loads(cache_path.read_text(encoding="utf-8")) + if entry["digest"] == digest(entry["body"]) and entry["url"] == url: + cached = entry + headers["If-None-Match"] = entry["etag"] + except (ValueError, KeyError, TypeError, OSError): + pass + method, data = ("GET", None) if query is None else ("POST", canonical_bytes(query)) + if data: + require(path == "/graphql" and "mutation" not in query["query"], "only built-in read query permitted") + headers["Content-Type"] = "application/json" + started = time.monotonic() + try: + code, response_headers, raw = self.transport.request(method, url, headers, data, + max(0.1, min(20, self.deadline - started))) + self.telemetry.append({"path": path, "status": code, "bytes": len(raw), + "elapsed_ms": int((time.monotonic() - started) * 1000)}) + if code not in (200, 304): + self.receipts.append({"path": path, "status": code, "body_digest": None}) + require(code in (200, 304), f"HTTP_{code}") + if code == 304: + require(cached is not None, "CACHE_304_WITHOUT_ADMITTED_BODY") + body = cached["body"] + else: + require(len(raw) <= MAX_RESPONSE, "RESPONSE_BYTE_CAP") + body = loads(raw.decode("utf-8")) + self.receipts.append({"path": path, "status": 200, "body_digest": digest(body)}) + if cache_path and code == 200 and response_headers.get("etag"): + try: + cache_path.parent.mkdir(parents=True, exist_ok=True) + atomic(cache_path, canonical_bytes({"url": url, "etag": response_headers["etag"], "body": body, "digest": digest(body)})) + except OSError: + self.telemetry[-1]["cache_write_failed"] = True + return body, response_headers + except (ValueError, UnicodeError, OSError, HTTPException) as exc: + if isinstance(exc, ScopeError): + raise + raise ScopeError("RESPONSE_OR_CACHE_UNAVAILABLE") from exc + + def obj(self, path): + try: + body, _ = self.get(path) + require(isinstance(body, dict), "OBJECT_RESPONSE_REQUIRED") + return body, None + except ScopeError as exc: + return None, str(exc) + + def pages(self, path, params=None, key=None): + items, seen = [], set() + for page in range(1, self.max_pages + 1): + try: + body, headers = self.get(path, {**(params or {}), "per_page": 100, "page": page}) + entries = body[key] if key else body + require(isinstance(entries, list) and len(entries) <= 100, "INVALID_LIST_RESPONSE") + for entry in entries: + require(isinstance(entry, dict), "LIST_IDENTITY_MISSING") + marker = entry.get("id", entry.get("filename", entry.get("new_path"))) + require(marker is not None, "LIST_IDENTITY_MISSING") + marker = str(marker) + require(marker not in seen, "PAGINATION_IDENTITY_SHIFT") + seen.add(marker); items.append(entry) + next_page = headers.get("x-next-page") if self.provider == "gitlab" else 'rel="next"' in headers.get("link", "") + # Never follow server-supplied URLs with credentials. Request our own page. + if not next_page and len(entries) < 100: + if key and body.get("total_count", len(items)) > len(items): + raise ScopeError("SERVER_RESULT_CAP") + return complete(items) + if self.provider == "gitlab" and "x-next-page" in headers and not next_page: + return complete(items) + except (ScopeError, KeyError, TypeError) as exc: + return {"status": "PARTIAL" if items else "UNAVAILABLE", "items": items, + "reasons": [str(exc) if isinstance(exc, ScopeError) else "INVALID_LIST_RESPONSE"]} + return {"status": "PARTIAL", "items": items, "reasons": ["PAGE_CAP"]} + + +def _map(coll, mapper): + items, reasons = [], list(coll["reasons"]) + for item in coll["items"]: + try: + items.append(mapper(item)) + except (ScopeError, KeyError, TypeError, ValueError, AttributeError): + reasons.append("SOURCE_OBJECT_MALFORMED") + return {"status": "PARTIAL" if reasons and items else "UNAVAILABLE" if reasons else "COMPLETE", + "items": items, "reasons": sorted(set(reasons))} + + +def _owners(value): + return sorted({a["login"] if "login" in a else a["username"] for a in value.get("assignees", [])}) + + +def _gh_issue(i): + return {"id": str(i["number"]), "title": i["title"], "state": "OPEN" if i["state"] == "open" else "CLOSED", + "owners": _owners(i), "updated_at": timestamp(i["updated_at"])} + + +def _gl_issue(i): + return {"id": str(i["iid"]), "title": i["title"], "state": "OPEN" if i["state"] == "opened" else "CLOSED", + "owners": _owners(i), "updated_at": timestamp(i["updated_at"])} + + +def _gh_change(client, repo, num): + route = f"/repos/{repo}/pulls/{num}" + p, err = client.obj(route) + require(p is not None, err or "CHANGE_UNAVAILABLE") + head = p["head"]["sha"] + raw_reviews = client.pages(route + "/reviews") + reviews = _map(raw_reviews, lambda r: {"id": str(r["id"]), "actor": r["user"]["login"], + "revision": r["commit_id"], "state": r["state"], "at": timestamp(r["submitted_at"])}) + # Pending reviews have no verdict and are not approvals. + if raw_reviews["status"] == "COMPLETE": + reviews = _map(complete([r for r in raw_reviews["items"] if r.get("state") != "PENDING"]), + lambda r: {"id": str(r["id"]), "actor": r["user"]["login"], "revision": r["commit_id"], + "state": r["state"], "at": timestamp(r["submitted_at"])}) + runs = client.pages(f"/repos/{repo}/commits/{head}/check-runs", key="check_runs") + checks = _map(runs, lambda c: {"id": "check:" + str(c["id"]), "name": c["name"], "revision": c["head_sha"], + "state": "PENDING" if c["status"] != "completed" else "PASS" if c.get("conclusion") == "success" else + "UNKNOWN" if c.get("conclusion") in (None, "neutral", "skipped") else "FAIL", + "at": timestamp(c.get("completed_at") or c["started_at"])}) + statuses = _map(client.pages(f"/repos/{repo}/commits/{head}/statuses"), lambda c: { + "id": "status:" + str(c["id"]), "name": c["context"], "revision": head, + "state": {"success": "PASS", "failure": "FAIL", "error": "FAIL", "pending": "PENDING"}[c["state"]], + "at": timestamp(c["updated_at"])}) + checks = {"status": "COMPLETE" if checks["status"] == statuses["status"] == "COMPLETE" else "PARTIAL", + "items": checks["items"] + statuses["items"], "reasons": sorted(set(checks["reasons"] + statuses["reasons"]))} + files = _map(client.pages(route + "/files"), lambda f: path(f["filename"])) + # GitHub's file listing is capped at 3000, independently of page budget. + if p.get("changed_files") != len(files["items"]): + files["status"] = "PARTIAL"; files["reasons"] = sorted(set(files["reasons"] + ["FILES_RESULT_CAP"])) + threads, train = unavailable("GRAPHQL_UNAVAILABLE"), None + owner, name = repo.split("/") + cursor, thread_items = None, [] + for _ in range(client.max_pages): + query = "query($owner:String!,$name:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$number){headRefOid mergeQueueEntry{id} reviewThreads(first:100,after:$cursor){nodes{id isResolved} pageInfo{hasNextPage endCursor}}}}}" + try: + body, _ = client.get("/graphql", query={"query": query, "variables": {"owner": owner, "name": name, "number": int(num), "cursor": cursor}}) + require(not body.get("errors"), "GRAPHQL_CAPABILITY_UNAVAILABLE") + obj = body["data"]["repository"]["pullRequest"] + require(obj["headRefOid"] == head, "HEAD_CHANGED_DURING_COLLECTION") + train = obj["mergeQueueEntry"] is not None + node = obj["reviewThreads"] + thread_items += [{"id": r["id"], "resolved": r["isResolved"]} for r in node["nodes"]] + if not node["pageInfo"]["hasNextPage"]: + threads = complete(thread_items); break + next_cursor = node["pageInfo"]["endCursor"] + require(next_cursor and next_cursor != cursor, "GRAPHQL_CURSOR_SHIFT") + cursor = next_cursor + threads = {"status": "PARTIAL", "items": thread_items, "reasons": ["PAGE_CAP"]} + except (ScopeError, KeyError, TypeError) as exc: + threads = {"status": "PARTIAL" if thread_items else "UNAVAILABLE", "items": thread_items, + "reasons": [str(exc) if isinstance(exc, ScopeError) else "GRAPHQL_MALFORMED"]}; break + final, failure = client.obj(route) + if final is None or final.get("head", {}).get("sha") != head or final.get("base", {}).get("sha") != p["base"]["sha"] or final.get("updated_at") != p["updated_at"]: + reviews = unavailable(failure or "CHANGE_MOVED_DURING_COLLECTION") + checks = unavailable(failure or "CHANGE_MOVED_DURING_COLLECTION") + threads = unavailable(failure or "CHANGE_MOVED_DURING_COLLECTION") + return {"id": str(p["number"]), "title": p["title"], "state": "MERGED" if p.get("merged") else "OPEN" if p["state"] == "open" else "CLOSED", + "author": p["user"]["login"], "owners": _owners(p), + "reviewers": sorted({r["login"] for r in p.get("requested_reviewers", [])}), "head": head, "base": p["base"]["sha"], + "updated_at": timestamp(p["updated_at"]), "draft": p.get("draft"), + "conflict": None if p.get("mergeable") is None else not p["mergeable"], "merge_train": train, + "reviews": reviews, "checks": checks, "threads": threads, "files": files} + + +def _gl_change(client, project, num): + route = f"/projects/{project}/merge_requests/{num}" + p, err = client.obj(route); require(p is not None, err or "CHANGE_UNAVAILABLE") + head = p["sha"] + # GitLab aggregate approval endpoint does not identify the reviewed SHA. + # Never fabricate exact-head approvals from it; explicit unknown is safe. + approval, approval_err = client.obj(route + "/approvals") + reviews = unavailable(approval_err or "GITLAB_EXACT_HEAD_APPROVAL_UNAVAILABLE") + discussions = client.pages(route + "/discussions") + def discussion(d): + notes = [n for n in d["notes"] if n.get("resolvable")] + return {"id": d["id"], "resolved": all(n.get("resolved") is True for n in notes)} + threads = _map(discussions, discussion) + diffs = client.pages(route + "/diffs") + # Diff records have no native id; use safe path identity in Client.pages. + files = _map(diffs, lambda d: path(d["new_path"])) + count = p.get("changes_count") + if not isinstance(count, str) or not count.isdigit() or int(count) != len(files["items"]): + files["status"] = "PARTIAL"; files["reasons"] = sorted(set(files["reasons"] + ["FILES_RESULT_CAP_OR_UNKNOWN_COUNT"])) + trains = client.pages(f"/projects/{project}/merge_trains", {"scope": "active"}) + train = None + if trains["status"] == "COMPLETE": + try: + train = any(str(t["merge_request"]["iid"]) == str(num) for t in trains["items"]) + except (KeyError, TypeError): + pass + pipes = client.pages(route + "/pipelines") + checks = complete() + exact = [v for v in pipes["items"] if v.get("sha") == head] + if pipes["status"] != "COMPLETE": + checks = unavailable("PIPELINE_INVENTORY_INCOMPLETE") + elif exact: + pipeline = max(exact, key=lambda v: int(v["id"])) + jobs = client.pages(f"/projects/{project}/pipelines/{pipeline['id']}/jobs", {"include_retried": "true"}) + checks = _map(jobs, lambda j: {"id": str(j["id"]), "name": j["name"], "revision": head, + "state": "PASS" if j["status"] == "success" else "FAIL" if j["status"] in ("failed", "canceled") else + "MANUAL" if j["status"] == "manual" else "UNKNOWN" if j["status"] == "skipped" else "PENDING", + "at": timestamp(j.get("finished_at") or j.get("started_at") or j["created_at"])}) + final, failure = client.obj(route) + if final is None or final.get("sha") != head or final.get("updated_at") != p["updated_at"]: + checks = unavailable(failure or "CHANGE_MOVED_DURING_COLLECTION") + threads = unavailable(failure or "CHANGE_MOVED_DURING_COLLECTION") + return {"id": str(p["iid"]), "title": p["title"], "state": {"opened": "OPEN", "merged": "MERGED", "closed": "CLOSED", "locked": "OPEN"}[p["state"]], + "author": p["author"]["username"], "owners": _owners(p), + "reviewers": sorted({r["username"] for r in p.get("reviewers", [])}), "head": head, + "base": p["diff_refs"]["base_sha"], "updated_at": timestamp(p["updated_at"]), "draft": p.get("draft"), + "conflict": p.get("has_conflicts"), "merge_train": train, + "reviews": reviews, "checks": checks, "threads": threads, "files": files} + + +def collect(client, locator, config, at=None, revision=None, context="CANONICAL", selected=None): + policy(config) + require(isinstance(locator, str) and (locator.isdigit() if client.provider == "gitlab" else + re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", locator) and + all(v not in (".", "..") for v in locator.split("/"))), "invalid project locator") + project_route = f"/repos/{locator}" if client.provider == "github" else f"/projects/{locator}" + meta, failure = client.obj(project_route) + require(meta is not None, failure or "PROJECT_IDENTITY_UNAVAILABLE") + branch = meta["default_branch"] + ref_route = project_route + ("/commits/" if client.provider == "github" else "/repository/commits/") + quote(branch, safe="") + commit, failure = client.obj(ref_route) + require(commit is not None, failure or "DEFAULT_REVISION_UNAVAILABLE") + default_sha = commit["sha"] if client.provider == "github" else commit["id"] + require(context != "CANONICAL" or revision in (None, default_sha), "canonical context must name current default revision") + project = {"provider": client.provider, "endpoint": client.endpoint, "project_id": str(meta["id"]), + "locator": meta["full_name"] if client.provider == "github" else str(meta["id"]), + "revision": revision or default_sha, "context": context} + subject(project) + change_fn, issue_fn = (_gh_change, _gh_issue) if client.provider == "github" else (_gl_change, _gl_issue) + change_route = project_route + ("/pulls" if client.provider == "github" else "/merge_requests") + if selected is not None: + refs = [selected["source"].split(":", 1)[1]] if selected["source"].startswith("change:") else [] + for criterion in config["criteria"]: + if selected.get("criterion") == criterion["id"]: + refs += criterion["implementations"] + changes = complete() + else: + changes = client.pages(change_route, {"state": "open" if client.provider == "github" else "opened", "scope": "all"} if client.provider == "gitlab" else {"state": "open"}) + refs = [str(v["number"] if client.provider == "github" else v["iid"]) for v in changes["items"]] + refs += [r for c in config["criteria"] for r in c["implementations"]] + mapped_changes, errors = [], list(changes["reasons"]) + for ref in sorted(set(refs), key=lambda v: int(v)): + try: + mapped_changes.append(change_fn(client, locator, ref)) + except (ScopeError, KeyError, TypeError, ValueError) as exc: + errors.append(str(exc) if isinstance(exc, ScopeError) else "CHANGE_MALFORMED") + changes = {"status": "PARTIAL" if errors and mapped_changes else "UNAVAILABLE" if errors else "COMPLETE", + "items": mapped_changes, "reasons": sorted(set(errors))} + issues = complete() + issue_refs = {c["issue"] for c in config["criteria"]} | {d for c in config["criteria"] for d in c["dependencies"]} | {d for r in config["requests"] for d in r["dependencies"]} + if selected is not None: + issue_refs = set(selected["dependencies"]) + if selected["source"].startswith("issue:"): + issue_refs.add(selected["source"].split(":", 1)[1]) + for ref in sorted(issue_refs, key=lambda v: int(v)): + raw, failure = client.obj(project_route + "/issues/" + quote(ref, safe="")) + try: + require(raw is not None and "pull_request" not in raw, failure or "ISSUE_UNAVAILABLE") + issues["items"].append(issue_fn(raw)) + except (ScopeError, KeyError, TypeError, ValueError): + issues["status"] = "PARTIAL" if issues["items"] else "UNAVAILABLE" + issues["reasons"] = ["ISSUE_OR_DEPENDENCY_UNAVAILABLE"] + now = timestamp(at or datetime.now(timezone.utc).isoformat()) + result = {"contract": CONTRACT, "kind": "inventory", "subject": project, "observed_at": now, + "changes": changes, "issues": issues, "receipts": client.receipts, + "selection": {"changes": "SELECTED" if selected is not None else "OPEN_AND_REGISTERED", "issues": sorted(issue_refs)}} + return inventory(result) diff --git a/src/devostasis/workscope/evidence.py b/src/devostasis/workscope/evidence.py new file mode 100644 index 0000000..57de4b0 --- /dev/null +++ b/src/devostasis/workscope/evidence.py @@ -0,0 +1,193 @@ +"""Bounded report profiles. Reports are data and never execution instructions.""" + +from __future__ import annotations + +import base64 +import binascii +import json +import re +from decimal import Decimal +from xml.etree import ElementTree + +from ..canonical import digest, digest_bytes +from . import CONTRACT +from .model import ScopeError, fields, identity, instant, number, path, require, revision, subject, text + +MAX_REPORT_BYTES = 2 * 1024 * 1024 +PROFILES = ("sarif.v1", "junit.v1", "cobertura.v1", "performance.v1") + + +def empty_evidence(): + return {"contract": CONTRACT, "kind": "evidence", "sources": []} + + +def source(profile, producer, version, project, at, data): + require(len(data) <= MAX_REPORT_BYTES, "report exceeds byte budget") + return {"profile": profile, "producer": producer, "version": version, "subject": project, + "observed_at": at, "status": "COMPLETE", "reason": None, + "report": {"digest": digest_bytes(data), "base64": base64.b64encode(data).decode("ascii")}} + + +def _json(data): + def pairs(entries): + result = {} + for key, value in entries: + require(key not in result, "report contains duplicate JSON members") + result[key] = value + return result + def invalid(value): + raise ScopeError(f"invalid JSON constant {value}") + return json.loads(data, object_pairs_hook=pairs, parse_float=Decimal, parse_constant=invalid) + + +def _xml(data): + decoded = data.decode("utf-8-sig") + require(not re.search(r" 0, + "Coverage denominator unavailable") + output, locations = [], set() + for cls in root.iter("class"): + for line in cls.findall("./lines/line"): + hits = int(line.get("hits", "-1")); require(hits >= 0, "invalid line coverage") + location = (path(cls.get("filename")), int(line.get("number", "0"))) + number(location[1], 1, 10000000) + require(location not in locations, "duplicate coverage location") + locations.add(location) + if hits == 0: + output.append(_finding("cobertura.v1", producer, "uncovered-line", cls.get("filename"), + int(line.get("number", "0")), cls.get("name"), + "An instrumented executable line has zero recorded hits.", + "Assess the uncovered behavior and add a meaningful test or record a scoped disposition.")) + require(len(locations) == int(root.get("lines-valid")), "Coverage denominator contradicts line inventory") + return output + + +def _performance(data, producer): + doc = _json(data) + fields(doc, ("measurements",)) + output = [] + require(isinstance(doc["measurements"], list), "measurements required") + for m in doc["measurements"]: + fields(m, ("name", "path", "line", "baseline", "current", "limit_percent", "unit", "baseline_revision")) + number(m["baseline"], 1, 1000000000); number(m["current"], 0, 1000000000) + number(m["limit_percent"], 0, 1000); text(m["unit"]); revision(m["baseline_revision"]) + if m["current"] * 100 > m["baseline"] * (100 + m["limit_percent"]): + output.append(_finding("performance.v1", producer, m["name"], m["path"], m["line"], None, + f"Measured {m['current']} {m['unit']} versus {m['baseline']} at {m['baseline_revision']}; consumer limit {m['limit_percent']} percent.", + "Repeat the measurement under the declared producer profile and inspect the regression.")) + return output + + +def validate(value): + fields(value, ("contract", "kind", "sources")) + require(value["contract"] == CONTRACT and value["kind"] == "evidence", "unsupported evidence contract") + require(isinstance(value["sources"], list) and len(value["sources"]) <= 100, "invalid evidence sources") + seen = set() + for item in value["sources"]: + fields(item, ("profile", "producer", "version", "subject", "observed_at", "status", "reason", "report")) + require(item["profile"] in PROFILES, "unsupported report profile") + text(item["producer"]); text(item["version"]); subject(item["subject"]); instant(item["observed_at"]) + require(item["status"] in ("COMPLETE", "PARTIAL", "UNAVAILABLE"), "invalid report status") + key = (item["profile"], item["producer"]) + require(key not in seen, "duplicate report producer/profile"); seen.add(key) + if item["status"] == "UNAVAILABLE": + require(item["report"] is None, "unavailable report contains data"); text(item["reason"]) + else: + if item["status"] == "PARTIAL": + text(item["reason"]) + else: + require(item["reason"] is None, "complete report has missing evidence") + fields(item["report"], ("digest", "base64")) + encoded = item["report"]["base64"] + require(isinstance(encoded, str) and len(encoded) <= (MAX_REPORT_BYTES + 2) // 3 * 4, + "report exceeds byte budget") + try: + raw = base64.b64decode(encoded, validate=True) + except (ValueError, binascii.Error) as exc: + raise ScopeError("invalid base64 report") from exc + require(len(raw) <= MAX_REPORT_BYTES and base64.b64encode(raw).decode() == encoded, "noncanonical report encoding") + require(digest_bytes(raw) == item["report"]["digest"], "report digest mismatch") + return value + + +def normalize(value, project, at, ttl): + validate(value) + findings, gaps = {}, [] + parsers = dict(zip(PROFILES, (_sarif, _junit, _coverage, _performance))) + for entry in value["sources"]: + key = f"{entry['profile']}:{entry['producer']}" + if entry["status"] != "COMPLETE": + gaps.append({"source": key, "reason": entry["status"] + ":" + entry["reason"]}) + if entry["status"] == "UNAVAILABLE": + continue + if identity(entry["subject"]) != identity(project) or entry["subject"]["revision"] != project["revision"]: + gaps.append({"source": key, "reason": "REPORT_IDENTITY_OR_REVISION_MISMATCH"}); continue + age = (instant(at) - instant(entry["observed_at"])).total_seconds() + if age < 0 or age > ttl: + gaps.append({"source": key, "reason": "REPORT_EXPIRED_OR_FUTURE"}); continue + raw = base64.b64decode(entry["report"]["base64"]) + try: + parsed = parsers[entry["profile"]](raw, entry["producer"]) + require(len(parsed) <= 10000, "finding budget exceeded") + for finding in parsed: + require(finding["id"] not in findings, "duplicate finding location") + findings[finding["id"]] = {**finding, "evidence": entry["report"]["digest"], + "revision": project["revision"], "producer_version": entry["version"], + "completeness": entry["status"]} + except (ScopeError, ValueError, TypeError, AttributeError, KeyError, UnicodeError, ElementTree.ParseError) as exc: + # A malformed report cannot partly contribute current findings. + findings = {k: f for k, f in findings.items() if f["producer"] != entry["producer"] or f["profile"] != entry["profile"]} + gaps.append({"source": key, "reason": "REPORT_MALFORMED:" + type(exc).__name__}) + return {"findings": [findings[k] for k in sorted(findings)], "gaps": sorted(gaps, key=lambda g: (g["source"], g["reason"]))} diff --git a/src/devostasis/workscope/model.py b/src/devostasis/workscope/model.py new file mode 100644 index 0000000..5bad60a --- /dev/null +++ b/src/devostasis/workscope/model.py @@ -0,0 +1,233 @@ +"""Admission rules shared by collection, offline replay and consumer handoff.""" + +from __future__ import annotations + +import re +from datetime import datetime, timezone +from pathlib import PurePosixPath +from urllib.parse import unquote, urlsplit + +from ..canonical import canonical_bytes, digest +from . import CONTRACT + +QUEUES = ("review", "finish_merge", "implement_issue", "research", "analyze_code") +STATUSES = ("COMPLETE", "PARTIAL", "UNAVAILABLE") +ROLES = {"reviewer": ("review",), "developer": ("finish_merge", "implement_issue"), + "researcher": ("research",), "analyst": ("analyze_code",), "all": QUEUES} + + +class ScopeError(ValueError): + """An input or persisted consumer contract is inadmissible.""" + + +def require(condition, message): + if not condition: + raise ScopeError(message) + + +def fields(value, required, optional=(), label="object"): + require(isinstance(value, dict), f"{label}: expected object") + require(set(required) <= value.keys() <= set(required) | set(optional), + f"{label}: missing or unknown fields") + + +def text(value, label="text"): + require(isinstance(value, str) and 0 < len(value) <= 8192 and + not any(ord(c) < 32 for c in value), f"{label}: invalid text") + return value + + +def number(value, low=0, high=100000, label="integer"): + require(type(value) is int and low <= value <= high, f"{label}: invalid integer") + return value + + +def instant(value): + require(isinstance(value, str) and re.fullmatch( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})", value), + "timestamp: timezone-qualified RFC3339 instant required") + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc) + except ValueError as exc: + raise ScopeError("timestamp: invalid instant") from exc + + +def timestamp(value): + return instant(value).isoformat().replace("+00:00", "Z") + + +def path(value): + text(value, "repository path") + decoded = unquote(value) + parts = decoded.split("/") + require(not decoded.startswith("/") and "\\" not in decoded and ":" not in decoded + and all(p not in ("", ".", "..") for p in parts) + and str(PurePosixPath(decoded)) == decoded, "path: must be repository relative") + require(decoded == value, "path: encoded paths are not admitted") + return value + + +def strings(value, label, paths=False): + require(isinstance(value, list) and len(value) <= 10000, f"{label}: expected bounded list") + for item in value: + (path if paths else text)(item) + require(len(set(value)) == len(value), f"{label}: duplicates") + + +def revision(value): + require(isinstance(value, str) and re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", value), + "revision: full immutable commit id required") + return value + + +def subject(value): + fields(value, ("provider", "endpoint", "project_id", "locator", "revision", "context")) + require(value["provider"] in ("github", "gitlab"), "provider: unsupported") + parsed = urlsplit(value["endpoint"]) + require(parsed.scheme == "https" and parsed.hostname and not parsed.username and + not parsed.password and not parsed.query and not parsed.fragment and + not value["endpoint"].endswith("/"), "endpoint: HTTPS API base required") + text(value["project_id"]); require(value["project_id"].isdigit(), "numeric immutable project id required") + text(value["locator"]); revision(value["revision"]) + require(value["context"] in ("CANONICAL", "CANDIDATE"), "context: unsupported") + return value + + +def identity(value): + return {k: value[k] for k in ("provider", "endpoint", "project_id")} + + +def collection(value, validator): + fields(value, ("status", "items", "reasons")) + require(value["status"] in STATUSES, "collection: invalid status") + strings(value["reasons"], "collection reasons") + require(isinstance(value["items"], list) and len(value["items"]) <= 10000, + "collection: invalid items") + require(value["status"] != "COMPLETE" or not value["reasons"], "complete collection has gaps") + require(value["status"] == "COMPLETE" or value["reasons"], "incomplete collection requires reasons") + require(value["status"] != "UNAVAILABLE" or not value["items"], "unavailable collection has items") + seen = set() + for item in value["items"]: + validator(item) + key = item.get("id", digest(item)) if isinstance(item, dict) else item + require(key not in seen, "collection: duplicate identity") + seen.add(key) + + +def issue(value): + fields(value, ("id", "title", "state", "owners", "updated_at")) + text(value["id"]); text(value["title"]); strings(value["owners"], "owners") + require(value["state"] in ("OPEN", "CLOSED"), "issue: invalid state") + instant(value["updated_at"]) + + +def review(value): + fields(value, ("id", "actor", "revision", "state", "at")) + text(value["id"]); text(value["actor"]); revision(value["revision"]); instant(value["at"]) + require(value["state"] in ("APPROVED", "CHANGES_REQUESTED", "DISMISSED", "COMMENTED"), + "review: invalid state") + + +def check(value): + fields(value, ("id", "name", "revision", "state", "at")) + text(value["id"]); text(value["name"]); revision(value["revision"]); instant(value["at"]) + require(value["state"] in ("PASS", "FAIL", "PENDING", "MANUAL", "UNKNOWN"), "check: invalid state") + + +def thread(value): + fields(value, ("id", "resolved")) + text(value["id"]); require(type(value["resolved"]) is bool, "thread: resolved must be boolean") + + +def change(value): + fields(value, ("id", "title", "state", "author", "owners", "reviewers", "head", "base", "updated_at", + "draft", "conflict", "merge_train", "reviews", "checks", "threads", "files")) + text(value["id"]); text(value["title"]); text(value["author"]) + strings(value["owners"], "owners"); revision(value["head"]); revision(value["base"]) + strings(value["reviewers"], "requested reviewers") + instant(value["updated_at"]) + require(value["state"] in ("OPEN", "CLOSED", "MERGED"), "change: invalid state") + for key in ("draft", "conflict", "merge_train"): + require(type(value[key]) is bool or value[key] is None, f"change {key}: boolean or unknown required") + for key, validator in (("reviews", review), ("checks", check), ("threads", thread), ("files", path)): + collection(value[key], validator) + + +def inventory(value): + fields(value, ("contract", "kind", "subject", "observed_at", "changes", "issues", "receipts", "selection")) + require(value["contract"] == CONTRACT and value["kind"] == "inventory", "inventory: unsupported contract") + subject(value["subject"]); observed = instant(value["observed_at"]) + collection(value["changes"], change); collection(value["issues"], issue) + fields(value["selection"], ("changes", "issues")) + require(value["selection"]["changes"] in ("OPEN_AND_REGISTERED", "SELECTED"), "invalid change inventory scope") + strings(value["selection"]["issues"], "issue selection") + require(isinstance(value["receipts"], list) and len(value["receipts"]) <= 10000, "invalid receipts") + for obj in value["changes"]["items"] + value["issues"]["items"]: + require(instant(obj["updated_at"]) <= observed, "object changed after observation") + if "reviews" in obj: + for entry in obj["reviews"]["items"] + obj["checks"]["items"]: + require(instant(entry["at"]) <= observed, "evidence arrived after observation") + canonical_bytes(value) + return value + + +def policy(value): + fields(value, ("contract", "kind", "version", "actor", "roles", "ttl_seconds", "queue_order", + "required_checks", "minimum_approvals", "allow_merge_train", "read_budget", + "priorities", "criteria", "requests", "dispositions")) + require(value["contract"] == CONTRACT and value["kind"] == "policy", "policy: unsupported contract") + text(value["version"]); text(value["actor"]) + strings(value["roles"], "roles"); require(set(value["roles"]) <= ROLES.keys(), "unknown role") + number(value["ttl_seconds"], 1, 86400); number(value["minimum_approvals"], 1, 100) + require(type(value["allow_merge_train"]) is bool, "merge train policy: boolean required") + require(isinstance(value["queue_order"], list) and len(value["queue_order"]) == 5 and + set(value["queue_order"]) == set(QUEUES), "queue order: five queues exactly once") + strings(value["required_checks"], "required checks") + fields(value["read_budget"], ("max_files", "max_bytes")) + number(value["read_budget"]["max_files"], 1, 1000); number(value["read_budget"]["max_bytes"], 1, 10000000) + require(isinstance(value["priorities"], dict), "priorities: object required") + for key, entry in value["priorities"].items(): + text(key); fields(entry, ("rank", "rationale")); number(entry["rank"], 0, 1000); text(entry["rationale"]) + for category in ("criteria", "requests", "dispositions"): + require(isinstance(value[category], list) and len(value[category]) <= 10000, f"invalid {category}") + ids = set() + for item in value["criteria"]: + fields(item, ("id", "issue", "acceptance", "paths", "symbols", "dependencies", "implementations", "done")) + text(item["id"]); text(item["issue"]); require(item["issue"].isdigit(), "issue id must be numeric") + strings(item["acceptance"], "acceptance") + require(item["acceptance"], "criterion requires explicit acceptance") + strings(item["paths"], "paths", True); strings(item["symbols"], "symbols") + strings(item["dependencies"], "dependencies"); strings(item["implementations"], "implementations") + require(all(r.isdigit() for r in item["dependencies"] + item["implementations"]), "source ids must be numeric") + require(type(item["done"]) is bool, "criterion done: boolean required") + require(item["id"] not in ids, "duplicate criterion id"); ids.add(item["id"]) + ids = set() + for item in value["requests"]: + fields(item, ("id", "queue", "question", "paths", "symbols", "acceptance", "dependencies", "owner")) + text(item["id"]); text(item["question"]) + require(item["queue"] in ("research", "analyze_code"), "invalid request queue") + strings(item["paths"], "paths", True); strings(item["symbols"], "symbols") + strings(item["acceptance"], "acceptance"); strings(item["dependencies"], "dependencies") + require(all(r.isdigit() for r in item["dependencies"]), "dependency ids must be numeric") + require(item["acceptance"], "request requires expected output") + require(item["queue"] != "analyze_code" or item["paths"], "analysis requires named paths") + require(item["owner"] is None or isinstance(item["owner"], str), "invalid request owner") + if item["owner"] is not None: + text(item["owner"], "request owner") + require(item["id"] not in ids, "duplicate request id"); ids.add(item["id"]) + ids = set() + for item in value["dispositions"]: + fields(item, ("finding", "revision", "until", "state", "reference")) + text(item["finding"]); revision(item["revision"]); instant(item["until"]); text(item["reference"]) + require(item["state"] in ("ACKNOWLEDGED", "IMPLEMENTING"), "invalid disposition") + require(item["finding"] not in ids, "duplicate disposition"); ids.add(item["finding"]) + canonical_bytes(value) + return value + + +def default_policy(actor): + return {"contract": CONTRACT, "kind": "policy", "version": "example-1", "actor": actor, + "roles": ["all"], "ttl_seconds": 3600, "queue_order": list(QUEUES), + "required_checks": [], "minimum_approvals": 1, "allow_merge_train": False, + "read_budget": {"max_files": 20, "max_bytes": 200000}, "priorities": {}, + "criteria": [], "requests": [], "dispositions": []} diff --git a/src/devostasis/workscope/planner.py b/src/devostasis/workscope/planner.py new file mode 100644 index 0000000..0fe0bce --- /dev/null +++ b/src/devostasis/workscope/planner.py @@ -0,0 +1,210 @@ +"""Pure deterministic work projection from admitted inputs and explicit policy.""" + +from __future__ import annotations + +from datetime import timedelta + +from ..canonical import digest +from . import CONTRACT +from .evidence import normalize +from .model import QUEUES, ROLES, identity, instant, inventory, policy, require, timestamp + + +def latest_reviews(change): + actors = {} + for r in sorted(change["reviews"]["items"], key=lambda r: (instant(r["at"]), r["id"])): + if r["state"] != "COMMENTED": + actors[r["actor"]] = r + return actors + + +def merge_reasons(change, config): + blocked, missing = [], [] + for field, reason in (("draft", "DRAFT"), ("conflict", "CONFLICT")): + if change[field] is True: + blocked.append(reason) + elif change[field] is None: + missing.append(field.upper() + "_UNKNOWN") + if change["merge_train"] is True and not config["allow_merge_train"]: + blocked.append("MERGE_TRAIN_NOT_AUTHORIZED") + elif change["merge_train"] is None: + missing.append("MERGE_TRAIN_UNKNOWN") + for name in ("reviews", "checks", "threads", "files"): + if change[name]["status"] != "COMPLETE": + missing.append(name.upper() + "_INCOMPLETE") + reviews = latest_reviews(change) + current = [r for r in reviews.values() if r["revision"] == change["head"] and r["actor"] != change["author"]] + approvals = sum(r["state"] == "APPROVED" for r in current) + if any(r["state"] == "CHANGES_REQUESTED" for r in reviews.values()): + blocked.append("CHANGES_REQUESTED") + if approvals < config["minimum_approvals"]: + blocked.append("EXACT_HEAD_APPROVAL_REQUIRED") + checks = {} + for c in sorted(change["checks"]["items"], key=lambda c: (instant(c["at"]), c["id"])): + if c["revision"] == change["head"]: + checks[c["name"]] = c + for name in config["required_checks"]: + if name not in checks: + missing.append("CHECK_MISSING:" + name) + elif checks[name]["state"] == "UNKNOWN": + missing.append("CHECK_UNKNOWN:" + name) + elif checks[name]["state"] != "PASS": + blocked.append("CHECK_" + checks[name]["state"] + ":" + name) + if any(not t["resolved"] for t in change["threads"]["items"]): + blocked.append("UNRESOLVED_DISCUSSION") + return blocked, missing + + +def project(inv, config, evidence, at=None): + inventory(inv); policy(config) + at = timestamp(at or inv["observed_at"]) + require(instant(at) >= instant(inv["observed_at"]), "scope predates its inventory") + expired = (instant(at) - instant(inv["observed_at"])).total_seconds() > config["ttl_seconds"] + inputs = {"inventory": digest(inv), "policy": digest(config), "evidence": digest(evidence)} + normalized = normalize(evidence, inv["subject"], at, config["ttl_seconds"]) + items, excluded, gaps = [], [], [] + actor = config["actor"] + allowed = {q for role in config["roles"] for q in ROLES[role]} + issues = {i["id"]: i for i in inv["issues"]["items"]} + changes = {c["id"]: c for c in inv["changes"]["items"]} + + def dependencies(refs): + blocked, missing = [], [] + for ref in refs: + if ref not in issues: + missing.append("DEPENDENCY_UNKNOWN:" + ref) + elif issues[ref]["state"] != "CLOSED": + blocked.append("DEPENDENCY_OPEN:" + ref) + return blocked, missing + + def add(queue, source, criterion, revision, title, owner, paths, symbols, acceptance, + deps=(), blocked=(), missing=(), action=None, continuation=False, refs=(), merge_gate=None): + seed = {"project": identity(inv["subject"]), "queue": queue, "source": source, "criterion": criterion} + item_id = digest(seed) + if queue not in allowed: + excluded.append({"id": item_id, "source": source, "reason": "ROLE_NOT_ELIGIBLE"}); return + b, m = dependencies(deps) + b += list(blocked); m += list(missing) + if owner and owner != actor: + b.append("FOREIGN_IMPLEMENTATION_OWNER") + if expired and action != "recover_evidence": + m.append("INVENTORY_EXPIRED") + priority = config["priorities"].get(source, {"rank": 1000, "rationale": "No consumer business priority declared."}) + read_paths = sorted(set(paths))[:config["read_budget"]["max_files"]] + if len(set(paths)) > len(read_paths): + m.append("READ_SET_OVERFLOW") + item = {"id": item_id, "queue": queue, "source": source, "criterion": criterion, + "subject": inv["subject"], "revision": revision, "title": title, + "action": action or queue, "implementation_owner": owner, "executor": actor, + "priority": priority, "continuation": continuation, + "eligibility": "UNKNOWN" if m else "BLOCKED" if b else "READY", + "reasons": sorted(set(b + m)), "dependencies": list(deps), + "read_set": {"paths": read_paths, "symbols": sorted(set(symbols)), "budget": config["read_budget"], + "total_paths": len(set(paths))}, + "acceptance": list(acceptance), "evidence": sorted(set(refs) | set(inputs.values()))} + item["merge_gate"] = merge_gate + item["fingerprint"] = digest(item) + items.append(item) + + for name in ("changes", "issues"): + if inv[name]["status"] != "COMPLETE": + gaps.append({"source": name, "reason": name.upper() + "_" + inv[name]["status"]}) + needed_issues = {c["issue"] for c in config["criteria"]} | {d for c in config["criteria"] for d in c["dependencies"]} | {d for r in config["requests"] for d in r["dependencies"]} + for ref in sorted(needed_issues - issues.keys()): + gaps.append({"source": "issue:" + ref, "reason": "ISSUE_OR_DEPENDENCY_UNKNOWN"}) + for c in inv["changes"]["items"]: + if c["state"] != "OPEN": + excluded.append({"source": "change:" + c["id"], "reason": c["state"]}); continue + src = "change:" + c["id"] + owner = c["owners"][0] if len(c["owners"]) == 1 else c["author"] + blocked, missing = merge_reasons(c, config) + review = latest_reviews(c).get(actor) + if actor == c["author"]: + excluded.append({"source": src, "reason": "AUTHOR_CANNOT_INDEPENDENTLY_REVIEW"}) + elif review and review["revision"] == c["head"] and review["state"] in ("APPROVED", "CHANGES_REQUESTED"): + excluded.append({"source": src, "reason": "EXACT_HEAD_REVIEW_ALREADY_RECORDED"}) + elif c["reviews"]["status"] == "COMPLETE" and actor not in c["reviewers"] and sum( + r["revision"] == c["head"] and r["actor"] != c["author"] and r["state"] == "APPROVED" + for r in latest_reviews(c).values()) >= config["minimum_approvals"]: + excluded.append({"source": src, "reason": "INDEPENDENT_REVIEW_REQUIREMENT_SATISFIED"}) + else: + review_missing = [name.upper() + "_INCOMPLETE" for name in ("files", "reviews") if c[name]["status"] != "COMPLETE"] + add("review", src, None, {"head": c["head"], "base": c["base"]}, c["title"], None, + c["files"]["items"], [], ["Record an independent verdict on this exact head and scope."], + blocked=["DRAFT"] if c["draft"] else [], missing=review_missing + (["DRAFT_UNKNOWN"] if c["draft"] is None else [])) + if len(c["owners"]) > 1: + missing.append("IMPLEMENTATION_OWNER_AMBIGUOUS") + add("finish_merge", src, None, {"head": c["head"], "base": c["base"]}, c["title"], owner, + c["files"]["items"], [], ["Resolve the listed blockers and recheck this exact head before any authorized merge."], + blocked=[r for r in blocked if r == "MERGE_TRAIN_NOT_AUTHORIZED"], missing=missing, + action="merge_train_admission" if c["merge_train"] and not blocked and not missing else "merge" if not blocked and not missing else "rework", + continuation=owner == actor, + merge_gate={"status": "UNKNOWN" if missing else "BLOCKED" if blocked else "READY", "reasons": sorted(set(blocked + missing))}) + for name in ("reviews", "checks", "threads", "files"): + if c[name]["status"] != "COMPLETE": + gaps.append({"source": src + "/" + name, "reason": name.upper() + "_INCOMPLETE"}) + if any(c[name] is None for name in ("draft", "conflict", "merge_train")): + gaps.append({"source": src + "/merge_state", "reason": "MERGE_STATE_CAPABILITY_UNKNOWN"}) + for criterion in config["criteria"]: + src = "issue:" + criterion["issue"] + issue = issues.get(criterion["issue"]) + if criterion["done"] or issue and issue["state"] == "CLOSED": + excluded.append({"source": src, "criterion": criterion["id"], "reason": "CRITERION_DONE"}); continue + active = [changes[r] for r in criterion["implementations"] if r in changes and changes[r]["state"] in ("OPEN", "MERGED")] + if active: + excluded.append({"source": src, "criterion": criterion["id"], "reason": "EQUIVALENT_IMPLEMENTATION_EXISTS"}); continue + missing = [] + if issue is None: + missing.append("ISSUE_UNKNOWN") + if any(r not in changes for r in criterion["implementations"]): + missing.append("IMPLEMENTATION_UNKNOWN") + for ref in criterion["implementations"]: + if ref not in changes: + gaps.append({"source": "change:" + ref, "reason": "IMPLEMENTATION_UNKNOWN"}) + if not criterion["paths"]: + missing.append("IMPLEMENTATION_READ_SET_UNDECLARED") + gaps.append({"source": src + "/" + criterion["id"], "reason": "IMPLEMENTATION_READ_SET_UNDECLARED"}) + if inv["changes"]["status"] != "COMPLETE": + missing.append("ACTIVE_IMPLEMENTATION_INVENTORY_INCOMPLETE") + owners = issue["owners"] if issue else [] + if len(owners) > 1: + missing.append("IMPLEMENTATION_OWNER_AMBIGUOUS") + add("implement_issue", src, criterion["id"], {"content": inv["subject"]["revision"], + "issue": digest(issue) if issue else None}, issue["title"] if issue else criterion["id"], + owners[0] if len(owners) == 1 else None, criterion["paths"], criterion["symbols"], + criterion["acceptance"], criterion["dependencies"], missing=missing, + continuation=owners == [actor]) + for request in config["requests"]: + add(request["queue"], "request:" + request["id"], request["id"], {"content": inv["subject"]["revision"]}, + request["question"], request["owner"], request["paths"], request["symbols"], request["acceptance"], request["dependencies"]) + for finding in normalized["findings"]: + disposition = next((d for d in config["dispositions"] if d["finding"] == finding["id"] and + d["revision"] == finding["revision"] and instant(d["until"]) >= instant(at)), None) + if disposition and (disposition["state"] == "ACKNOWLEDGED" or disposition["reference"] in changes and + changes[disposition["reference"]]["state"] == "OPEN"): + excluded.append({"source": finding["id"], "reason": "CURRENT_DISPOSITION", "reference": disposition["reference"]}); continue + add("analyze_code", finding["id"], finding["rule"], {"content": finding["revision"]}, finding["trigger"], + None, [finding["path"]], [finding["symbol"]] if finding["symbol"] else [], [finding["verification"]], + refs=[finding["evidence"]], missing=["REPORT_PARTIAL"] if finding["completeness"] != "COMPLETE" else []) + gaps += normalized["gaps"] + if expired: + gaps.append({"source": "inventory", "reason": "INVENTORY_EXPIRED"}) + recovery = {} + for gap in gaps: + recovery.setdefault(gap["source"], set()).add(gap["reason"]) + for source in sorted(recovery): + add("research", "recovery:" + source, None, {"content": inv["subject"]["revision"]}, + "Recover bounded evidence: " + ", ".join(sorted(recovery[source])), None, [], [], + ["Refresh only this missing source under the declared collection budget; rebuild and verify scope."], + action="recover_evidence") + require(len({item["id"] for item in items}) == len(items), "duplicate scope item identity") + rank = {"READY": 0, "BLOCKED": 1, "UNKNOWN": 2} + items.sort(key=lambda i: (rank[i["eligibility"]], i["priority"]["rank"], not i["continuation"], + config["queue_order"].index(i["queue"]), i["id"])) + return {"contract": CONTRACT, "kind": "scope", "subject": inv["subject"], "observed_at": inv["observed_at"], + "planned_at": at, "expires_at": timestamp((instant(inv["observed_at"]) + timedelta(seconds=config["ttl_seconds"])).isoformat()), + "policy_version": config["version"], "inputs": inputs, "items": items, + "queues": {q: [i["id"] for i in items if i["queue"] == q] for q in QUEUES}, + "coverage": {k: inv[k]["status"] for k in ("changes", "issues")}, "selection": inv["selection"], + "excluded": sorted(excluded, key=digest), "gaps": gaps, + "authority": "Read-only proposal. Recheck source state and consumer authorization before execution."} diff --git a/tests/test_release_pins.py b/tests/test_release_pins.py index 51f08be..28e4019 100644 --- a/tests/test_release_pins.py +++ b/tests/test_release_pins.py @@ -26,9 +26,12 @@ Path(".github/workflows/observe-self.yml"), Path("README.md"), Path("docs/deployment.md"), + Path(".github/workflows/work-observe.yml"), + Path("examples/work/github-observe.yml"), + Path("examples/work/gitlab-observe.yml"), ) -WORKFLOW_REF = re.compile(r"observe-self\.yml@(v[0-9][^\s\"']*)") +WORKFLOW_REF = re.compile(r"(?:observe-self|work-observe)\.yml@(v[0-9][^\s\"']*)") # Case-insensitive and with the optional .git: GitHub resolves both spellings, # so a pin written either way is a pin that must resolve. INSTALL_REF = re.compile(r"github\.com/drevendev/devostasis(?:\.git)?@([^\s\"']+)", re.IGNORECASE) @@ -40,9 +43,10 @@ def test_the_package_version_and_the_module_agree(): assert declared == __version__, "pyproject.toml and devostasis.__version__ disagree" -def test_the_reusable_workflow_installs_the_version_it_ships_with(): +@pytest.mark.parametrize("workflow_path", (".github/workflows/observe-self.yml", ".github/workflows/work-observe.yml")) +def test_the_reusable_workflow_installs_the_version_it_ships_with(workflow_path): """A caller pinning the workflow at this tag and passing no ref gets this engine.""" - workflow = (ROOT / ".github/workflows/observe-self.yml").read_text(encoding="utf-8") + workflow = (ROOT / workflow_path).read_text(encoding="utf-8") match = DEFAULT_REF.search(workflow) assert match, "observe-self.yml no longer declares a default devostasis-ref" assert match.group(1) == TAG, f"default devostasis-ref is {match.group(1)}, expected {TAG}" diff --git a/tests/test_workscope.py b/tests/test_workscope.py new file mode 100644 index 0000000..76a6d60 --- /dev/null +++ b/tests/test_workscope.py @@ -0,0 +1,359 @@ +"""Implementation-owned DEV-WORK cases, distinct from research Vital vectors.""" + +import base64 +from copy import deepcopy +import json +from pathlib import Path + +import pytest + +from devostasis.canonical import canonical_bytes, digest, digest_bytes +from devostasis.cli import main +from devostasis.workscope import CONTRACT +from devostasis.workscope.bundle import build, latest, locked, publish, slice_scope, verify, verify_content +from devostasis.workscope.collect import Client, collect, complete, unavailable +from devostasis.workscope.evidence import empty_evidence, normalize, source, validate +from devostasis.workscope.model import ScopeError, default_policy, instant, inventory, path, policy +from devostasis.workscope.planner import project + +AT = "2026-10-02T12:00:00Z" +SHA = "a" * 40 +HEAD = "b" * 40 +BASE = "c" * 40 + + +def subject(): + return {"provider": "github", "endpoint": "https://api.github.com", "project_id": "123", + "locator": "acme/widget", "revision": SHA, "context": "CANONICAL"} + + +def change(author="owner"): + return {"id": "1", "title": "Change", "state": "OPEN", "author": author, "owners": [], "reviewers": ["executor"], + "head": HEAD, "base": BASE, "updated_at": AT, "draft": False, "conflict": False, + "merge_train": False, "reviews": complete([{"id": "r1", "actor": "reviewer", "revision": HEAD, + "state": "APPROVED", "at": AT}]), + "checks": complete([{"id": "c1", "name": "tests", "revision": HEAD, "state": "PASS", "at": AT}]), + "threads": complete(), "files": complete(["src/app.py"])} + + +def inv(): + return {"contract": CONTRACT, "kind": "inventory", "subject": subject(), "observed_at": AT, + "changes": complete([change()]), "issues": complete([{"id": "2", "title": "Feature", + "state": "OPEN", "owners": [], "updated_at": AT}]), "receipts": [], + "selection": {"changes": "OPEN_AND_REGISTERED", "issues": ["2"]}} + + +def config(actor="owner"): + p = default_policy(actor); p["required_checks"] = ["tests"] + p["criteria"] = [{"id": "C1", "issue": "2", "acceptance": ["Feature passes a behavioral test."], + "paths": ["src/app.py"], "symbols": ["app"], "dependencies": [], + "implementations": [], "done": False}] + p["requests"] = [{"id": "Q1", "queue": "research", "question": "Resolve the observed contradiction.", + "paths": ["src/app.py"], "symbols": [], "acceptance": ["Record bounded evidence and a verdict."], + "dependencies": [], "owner": None}] + return p + + +def report(profile="junit.v1", raw=None, project=None, at=AT): + raw = raw or b'' + return {"contract": CONTRACT, "kind": "evidence", "sources": [source(profile, "pytest", "1", project or subject(), at, raw)]} + + +def item(scope, queue): + return next(i for i in scope["items"] if i["queue"] == queue) + + +def test_work_five_queues_and_bounded_acceptance(): + result = project(inv(), config("executor"), report()) + assert all(result["queues"].values()) + assert all(i["acceptance"] and i["read_set"]["budget"] for i in result["items"]) + assert item(result, "review")["implementation_owner"] is None + assert item(result, "finish_merge")["executor"] == "executor" + + +def test_work_determinism_ids_and_explicit_priority(): + i, p = inv(), config() + p["priorities"]["issue:2"] = {"rank": 0, "rationale": "Customer commitment."} + first = project(i, p, report()) + assert first["items"][0]["queue"] == "implement_issue" + assert canonical_bytes(first) == canonical_bytes(project(deepcopy(i), deepcopy(p), report())) + newer = deepcopy(i); newer["observed_at"] = "2026-10-02T12:01:00Z" + second = project(newer, p, report()) + assert {v["id"] for v in first["items"]} == {v["id"] for v in second["items"]} + assert digest(first) != digest(second) + + +@pytest.mark.parametrize("state", ["FAIL", "PENDING", "MANUAL", "UNKNOWN"]) +def test_work_checks_block_merge_without_hiding_rework(state): + i = inv(); i["changes"]["items"][0]["checks"]["items"][0]["state"] = state + selected = item(project(i, config(), empty_evidence()), "finish_merge") + assert selected["action"] == "rework" + assert selected["merge_gate"]["status"] != "READY" + assert selected["eligibility"] == ("UNKNOWN" if state == "UNKNOWN" else "READY") + + +@pytest.mark.parametrize("field", ["reviews", "checks", "threads", "files"]) +@pytest.mark.parametrize("status", ["PARTIAL", "UNAVAILABLE"]) +def test_work_missing_cannot_admit_merge(field, status): + i = inv(); i["changes"]["items"][0][field] = {"status": status, "items": [], "reasons": ["AUTH"]} + s = project(i, config(), empty_evidence()) + assert item(s, "finish_merge")["merge_gate"]["status"] == "UNKNOWN" + assert any(v["action"] == "recover_evidence" for v in s["items"]) + + +@pytest.mark.parametrize("mutation,reason", [ + (lambda c: c.update(draft=True), "DRAFT"), + (lambda c: c.update(conflict=True), "CONFLICT"), + (lambda c: c.update(merge_train=True), "MERGE_TRAIN_NOT_AUTHORIZED"), + (lambda c: c["threads"]["items"].append({"id": "t1", "resolved": False}), "UNRESOLVED_DISCUSSION"), + (lambda c: c["reviews"]["items"][0].update(revision=SHA), "EXACT_HEAD_APPROVAL_REQUIRED"), + (lambda c: c["reviews"]["items"][0].update(state="CHANGES_REQUESTED"), "CHANGES_REQUESTED"), + (lambda c: c["checks"].update(items=[]), "CHECK_MISSING:tests"), +]) +def test_work_merge_admission_matrix(mutation, reason): + i = inv(); mutation(i["changes"]["items"][0]) + s = project(i, config(), empty_evidence()) + assert reason in item(s, "finish_merge")["merge_gate"]["reasons"] + + +def test_work_stale_verdict_dismissal_and_self_review(): + i = inv(); c = i["changes"]["items"][0] + c["reviews"]["items"].append({"id": "r2", "actor": "reviewer", "revision": HEAD, + "state": "DISMISSED", "at": AT}) + assert item(project(i, config(), empty_evidence()), "finish_merge")["merge_gate"]["status"] == "BLOCKED" + c["reviews"]["items"][0]["actor"] = "owner" + c["reviews"]["items"] = c["reviews"]["items"][:1] + assert item(project(i, config(), empty_evidence()), "finish_merge")["merge_gate"]["status"] == "BLOCKED" + assert not project(i, config(), empty_evidence())["queues"]["review"] + + +def test_work_roles_foreign_owners_dependencies_and_sibling_criteria(): + i, p = inv(), config() + i["issues"]["items"][0]["owners"] = ["someone"] + assert "FOREIGN_IMPLEMENTATION_OWNER" in item(project(i, p, empty_evidence()), "implement_issue")["reasons"] + i["issues"]["items"][0]["owners"] = [] + p["criteria"][0]["dependencies"] = ["3"] + assert item(project(i, p, empty_evidence()), "implement_issue")["eligibility"] == "UNKNOWN" + i["issues"]["items"].append({"id": "3", "title": "Dependency", "state": "OPEN", "owners": [], "updated_at": AT}) + assert item(project(i, p, empty_evidence()), "implement_issue")["eligibility"] == "BLOCKED" + i["issues"]["items"][-1]["state"] = "CLOSED" + sibling = deepcopy(p["criteria"][0]); sibling.update(id="C2", done=True); p["criteria"].append(sibling) + assert len(project(i, p, empty_evidence())["queues"]["implement_issue"]) == 1 + p["criteria"][0]["implementations"] = ["1"] + assert not project(i, p, empty_evidence())["queues"]["implement_issue"] + p["roles"] = ["analyst"] + s = project(i, p, report()) + assert len(s["items"]) == 1 and s["items"][0]["queue"] == "analyze_code" + + +@pytest.mark.parametrize("bad", ["../a.py", "/a.py", "C:/a.py", "a\\b.py", "%2e%2e/a.py", "a//b.py", "a/./b.py", "a.py\n"]) +def test_work_out_of_tree_paths_rejected(bad): + with pytest.raises(ScopeError): + path(bad) + + +@pytest.mark.parametrize("bad", ["2026-10-02", "2026-10-02T12:00:00", "2026-99-02T12:00:00Z", "2026-10-02T12:00:00.1234567Z"]) +def test_work_timestamp_requires_unambiguous_instant(bad): + with pytest.raises(ScopeError): + instant(bad) + + +@pytest.mark.parametrize("profile,raw", [ + ("sarif.v1", b'{"version":"2.1.0","runs":[{"results":[{"ruleId":"S1","locations":[{"physicalLocation":{"artifactLocation":{"uri":"src/app.py"},"region":{"startLine":4}}}]}]}]}'), + ("junit.v1", b''), + ("cobertura.v1", b''), + ("performance.v1", ('{"measurements":[{"name":"latency","path":"src/app.py","line":4,"baseline":100,"current":120,"limit_percent":10,"unit":"ms","baseline_revision":"' + SHA + '"}]}').encode()), +]) +def test_work_report_profiles(profile, raw): + normalized = normalize(report(profile, raw), subject(), AT, 3600) + assert len(normalized["findings"]) == 1 and not normalized["gaps"] + assert normalized["findings"][0]["path"] == "src/app.py" + + +@pytest.mark.parametrize("profile,raw", [ + ("sarif.v1", b'{"version":"2.1.0","version":"2.1.0","runs":[]}'), + ("sarif.v1", b'{"version":"2.1.0","runs":[{"results":[{}]}]}'), + ("sarif.v1", b'{"version":"2.1.0","runs":['), + ("junit.v1", b']>'), + ("junit.v1", b''), + ("junit.v1", b''), + ("cobertura.v1", b''), + ("cobertura.v1", b''), + ("cobertura.v1", b''), + ("junit.v1", b''), + ("sarif.v1", b'{"version":"2.1.0","runs":[]}'), +]) +def test_work_malformed_and_unavailable_reports_are_not_zero(profile, raw): + normalized = normalize(report(profile, raw), subject(), AT, 3600) + assert not normalized["findings"] and normalized["gaps"] + + +@pytest.mark.parametrize("mutate", [lambda s: s.update(project_id="999"), lambda s: s.update(revision=HEAD)]) +def test_work_report_wrong_repo_and_revision(mutate): + s = subject(); mutate(s) + n = normalize(report(project=s), subject(), AT, 3600) + assert not n["findings"] and n["gaps"][0]["reason"] == "REPORT_IDENTITY_OR_REVISION_MISMATCH" + + +def test_work_stale_duplicate_and_tampered_reports(): + assert normalize(report(at="2026-10-01T12:00:00Z"), subject(), AT, 3600)["gaps"] + r = report(); r["sources"].append(deepcopy(r["sources"][0])) + with pytest.raises(ScopeError, match="duplicate"): + validate(r) + r = report(); r["sources"][0]["report"]["digest"] = "sha256:" + "0" * 64 + with pytest.raises(ScopeError, match="digest"): + validate(r) + + +def test_work_partial_report_retains_location_without_executable_admission(): + r = report(); r["sources"][0].update(status="PARTIAL", reason="PRODUCER_CAP") + s = project(inv(), config(), r) + assert item(s, "analyze_code")["eligibility"] == "UNKNOWN" + assert any(i["action"] == "recover_evidence" for i in s["items"]) + + +def test_work_disposition_expires_and_revision_recurs(): + i, p, r = inv(), config(), report() + finding = normalize(r, subject(), AT, 3600)["findings"][0] + p["dispositions"] = [{"finding": finding["id"], "revision": SHA, "until": "2026-10-02T12:01:00Z", + "state": "ACKNOWLEDGED", "reference": "issue:2"}] + assert not project(i, p, r)["queues"]["analyze_code"] + assert project(i, p, r, "2026-10-02T12:02:00Z")["queues"]["analyze_code"] + i["subject"]["revision"] = HEAD; r["sources"][0]["subject"]["revision"] = HEAD + assert project(i, p, r)["queues"]["analyze_code"] + + +def test_work_bundle_replay_and_forged_rehashed_projection_rejected(): + m, content = build(inv(), config(), report()) + assert verify_content(content)["manifest.json"] == m + c = deepcopy(content); s = json.loads(c["scope.json"]); s["items"][0]["eligibility"] = "MADE_UP" + c["scope.json"] = canonical_bytes(s) + forged = json.loads(c["manifest.json"]); forged["members"]["scope.json"] = digest_bytes(c["scope.json"]) + forged["bundle_id"] = digest({k: v for k, v in forged.items() if k != "bundle_id"}) + c["manifest.json"] = canonical_bytes(forged) + with pytest.raises(ScopeError, match="replay"): + verify_content(c) + + +def test_work_store_backfill_candidate_rename_and_latest_binding(tmp_path): + i = inv(); _, content = build(i, config(), report()); dest = publish(tmp_path, content) + root = dest.parent.parent + assert publish(tmp_path, content) == dest + old = deepcopy(i); old["observed_at"] = "2026-10-02T11:00:00Z" + for obj in old["changes"]["items"] + old["issues"]["items"]: + obj["updated_at"] = old["observed_at"] + for row in old["changes"]["items"][0]["checks"]["items"] + old["changes"]["items"][0]["reviews"]["items"]: + row["at"] = old["observed_at"] + publish(tmp_path, build(old, config(), empty_evidence())[1]) + assert latest(root)["scope.json"]["observed_at"] == AT + candidate = deepcopy(i); candidate["subject"]["context"] = "CANDIDATE"; candidate["observed_at"] = "2026-10-02T12:01:00Z" + publish(tmp_path, build(candidate, config(), report())[1]) + assert latest(root)["scope.json"]["observed_at"] == AT + i["observed_at"] = "2026-10-02T12:02:00Z"; i["subject"]["locator"] = "new/name" + renamed = publish(tmp_path, build(i, config(), report())[1]) + assert renamed.parent.parent == root and latest(root)["scope.json"]["subject"]["locator"] == "new/name" + pointer = json.loads((root / "latest.json").read_bytes()); pointer["manifest_digest"] = "bad" + (root / "latest.json").write_bytes(canonical_bytes(pointer)) + with pytest.raises(ScopeError, match="binding"): + latest(root) + + +def test_work_store_lock_and_failed_pointer_write_preserve_history(tmp_path, monkeypatch): + _, content = build(inv(), config(), report()); dest = publish(tmp_path, content); root = dest.parent.parent + with locked(root / ".lock"): + with pytest.raises(ScopeError, match="locked"): + publish(tmp_path, content) + i = inv(); i["observed_at"] = "2026-10-02T12:01:00Z" + def fail(*args): + raise OSError("disk failure") + monkeypatch.setattr("devostasis.workscope.bundle.atomic", fail) + with pytest.raises(OSError): + publish(tmp_path, build(i, config(), report())[1]) + assert latest(root)["scope.json"]["observed_at"] == AT + assert len(list((root / "bundles").iterdir())) == 2 + + +def test_work_cursor_exhaustive_binding_and_expiry(): + s = project(inv(), config("executor"), report()) + seen, cursor = [], None + while True: + result = slice_scope(s, limit=1, cursor=cursor, at=AT) + seen += [i["id"] for i in result["items"]]; cursor = result["cursor"] + if not cursor: + break + assert seen == [i["id"] for i in s["items"]] + c = slice_scope(s, limit=1)["cursor"] + with pytest.raises(ScopeError, match="another"): + slice_scope(s, role="analyst", cursor=c) + expired = slice_scope(s, at="2026-10-02T14:00:00Z") + assert expired["status"] == "EXPIRED" and len(expired["items"]) == 1 + + +class Fake: + def __init__(self, routes): + self.routes, self.calls = routes, [] + def request(self, method, url, headers, data, timeout): + self.calls.append((method, url, headers, data)) + path = url.split("https://api.github.com", 1)[-1].split("?", 1)[0] + value = self.routes.get(path, (404, {}, {})) + return value[0], value[1], canonical_bytes(value[2]) + + +def test_work_transport_caps_and_cache_partition(tmp_path): + transport = Fake({"/x": (200, {"etag": "e1"}, [{"id": 1}])}) + client = Client("github", "https://api.github.com", "secret-a", transport=transport, max_requests=1, cache=tmp_path) + assert client.pages("/x")["status"] == "COMPLETE" + assert client.pages("/x")["status"] == "UNAVAILABLE" + other = Client("github", "https://api.github.com", "secret-b", transport=transport, cache=tmp_path) + other.pages("/x") + assert "If-None-Match" not in transport.calls[-1][2] + assert "secret-a" not in "".join(p.read_text() for p in tmp_path.rglob("*.json")) + + +def test_work_pagination_duplicate_and_page_cap(): + f = Fake({"/x": (200, {"link": '; rel="next"'}, [{"id": i} for i in range(100)])}) + client = Client("github", "https://api.github.com", transport=f, max_pages=1) + assert client.pages("/x")["status"] == "PARTIAL" + client = Client("github", "https://api.github.com", transport=f, max_pages=2) + assert "PAGINATION_IDENTITY_SHIFT" in client.pages("/x")["reasons"] + assert all("evil.test" not in c[1] for c in f.calls) + + +def test_work_live_shape_github_adapter_and_readonly_graphql(): + repo = "/repos/acme/widget"; pr = repo + "/pulls/1" + p = {"number": 1, "title": "Change", "state": "open", "merged": False, "head": {"sha": HEAD}, + "base": {"sha": BASE}, "user": {"login": "owner"}, "assignees": [], "updated_at": AT, + "draft": False, "mergeable": True, "changed_files": 1} + routes = {repo: (200, {}, {"id": 123, "default_branch": "main", "full_name": "acme/widget"}), + repo + "/commits/main": (200, {}, {"sha": SHA}), repo + "/pulls": (200, {}, [{"id": 99, **p}]), + pr: (200, {}, p), pr + "/reviews": (200, {}, []), pr + "/files": (200, {}, [{"filename": "src/app.py"}]), + repo + f"/commits/{HEAD}/check-runs": (200, {}, {"total_count": 0, "check_runs": []}), + repo + f"/commits/{HEAD}/statuses": (200, {}, []), + repo + "/issues/2": (200, {}, {"number": 2, "title": "Feature", "state": "open", "assignees": [], "updated_at": AT}), + "/graphql": (200, {}, {"data": {"repository": {"pullRequest": {"headRefOid": HEAD, + "mergeQueueEntry": None, "reviewThreads": {"nodes": [], "pageInfo": {"hasNextPage": False, "endCursor": None}}}}}})} + f = Fake(routes); c = Client("github", "https://api.github.com", transport=f) + i = collect(c, "acme/widget", config(), AT) + assert i["changes"]["status"] == "COMPLETE" and i["changes"]["items"][0]["files"]["items"] == ["src/app.py"] + assert all(m == "GET" or m == "POST" and b"mutation" not in data for m, _, _, data in f.calls) + assert i["changes"]["items"][0]["threads"]["status"] == "COMPLETE" + + +def test_work_cli_offline_end_to_end_and_recheck(tmp_path, capsys): + for name, value in (("inventory", inv()), ("policy", config()), ("evidence", report())): + (tmp_path / (name + ".json")).write_bytes(canonical_bytes(value)) + args = ["--inventory", str(tmp_path / "inventory.json"), "--policy", str(tmp_path / "policy.json"), + "--evidence", str(tmp_path / "evidence.json"), "--store", str(tmp_path / "store")] + assert main(["work", "build", *args]) == 0 + output = json.loads(capsys.readouterr().out); dest = output["path"] + assert main(["work", "verify", "--bundle", dest]) == 0; capsys.readouterr() + scope = verify(dest)["scope.json"]; selected = item(scope, "finish_merge") + recheck_args = ["work", "recheck", "--bundle", dest, "--inventory", str(tmp_path / "inventory.json"), + "--policy", str(tmp_path / "policy.json"), "--item", selected["id"], "--at", AT, + "--actor", "owner", "--policy-version", "example-1", "--expected-project-id", "123"] + assert main(recheck_args) == 0 + assert json.loads(capsys.readouterr().out)["status"] == "RECHECKED" + i = inv(); i["changes"]["items"][0]["head"] = SHA + (tmp_path / "inventory.json").write_bytes(canonical_bytes(i)) + assert main(recheck_args) == 3 + assert json.loads(capsys.readouterr().out)["status"] == "INVALIDATED" diff --git a/tests/test_workscope_adapters.py b/tests/test_workscope_adapters.py new file mode 100644 index 0000000..93b2c84 --- /dev/null +++ b/tests/test_workscope_adapters.py @@ -0,0 +1,245 @@ +"""Provider capability, transport and consumer invalidation regression cases.""" +from copy import deepcopy +import json +from urllib.parse import urlsplit +from urllib.request import Request + +import pytest + +from devostasis.canonical import canonical_bytes, digest +from devostasis.cli import main +from devostasis.workscope.bundle import build, publish, verify +from devostasis.workscope.cli import recheck +from devostasis.workscope.collect import Client, HTTPTransport, SafeRedirect, collect +from devostasis.workscope.evidence import empty_evidence +from devostasis.workscope.model import ScopeError, identity, inventory, policy +from devostasis.workscope.planner import project +from test_workscope import AT, SHA, HEAD, BASE, config, inv, item + + +class Routes: + def __init__(self, routes): + self.routes, self.calls = routes, [] + def request(self, method, url, headers, data, timeout): + self.calls.append((method, url, headers, data)) + value = self.routes.get(urlsplit(url).path, (404, {}, {})) + if callable(value): + value = value(self.calls) + return value[0], value[1], canonical_bytes(value[2]) + + +def gitlab_routes(): + root = "/api/v4/projects/123"; mr = root + "/merge_requests/1" + detail = {"id": 99, "iid": 1, "title": "Feature", "state": "opened", "author": {"username": "owner"}, + "assignees": [], "sha": HEAD, "diff_refs": {"base_sha": BASE}, "updated_at": AT, + "draft": False, "has_conflicts": False, "changes_count": "1"} + return {root: (200, {}, {"id": 123, "default_branch": "main"}), + root + "/repository/commits/main": (200, {}, {"id": SHA}), + root + "/merge_requests": (200, {"x-next-page": ""}, [detail]), mr: (200, {}, detail), + mr + "/approvals": (200, {}, {"approved": True, "approved_by": [{"user": {"username": "reviewer"}, "approved_at": AT}]}), + mr + "/discussions": (200, {"x-next-page": ""}, [{"id": "d1", "notes": [{"resolvable": True, "resolved": False}]}]), + mr + "/diffs": (200, {"x-next-page": ""}, [{"new_path": "src/app.py", "old_path": "src/app.py"}]), + mr + "/pipelines": (200, {"x-next-page": ""}, [{"id": 101, "sha": HEAD}]), + root + "/pipelines/101/jobs": (200, {"x-next-page": ""}, [{"id": 102, "name": "tests", "status": "manual", "created_at": AT}]), + root + "/merge_trains": (200, {"x-next-page": ""}, []), + root + "/issues/2": (200, {}, {"iid": 2, "title": "Feature", "state": "opened", "assignees": [], "updated_at": AT})} + + +def test_work_gitlab_manual_jobs_diffs_threads_and_exact_head_unknown(): + f = Routes(gitlab_routes()) + i = collect(Client("gitlab", "https://gitlab.example/api/v4", "read-only", transport=f), "123", config(), AT) + c = i["changes"]["items"][0] + assert c["files"]["items"] == ["src/app.py"] and c["files"]["status"] == "COMPLETE" + assert c["reviews"]["status"] == "UNAVAILABLE" and c["merge_train"] is False + assert c["checks"]["items"][0]["state"] == "MANUAL" + assert c["threads"]["items"][0]["resolved"] is False + assert item(project(i, config(), empty_evidence()), "finish_merge")["merge_gate"]["status"] == "UNKNOWN" + assert all(call[0] == "GET" for call in f.calls) + assert all(call[2]["PRIVATE-TOKEN"] == "read-only" for call in f.calls) + + +@pytest.mark.parametrize("suffix", ["/issues/2", "/merge_requests/1/discussions", "/pipelines/101/jobs", "/merge_trains", "/merge_requests/1/approvals"]) +@pytest.mark.parametrize("code", [401, 403, 404, 429, 500]) +def test_work_gitlab_missing_capabilities_are_explicit(suffix, code): + routes = gitlab_routes(); routes["/api/v4/projects/123" + suffix] = (code, {}, {}) + f = Routes(routes); i = collect(Client("gitlab", "https://gitlab.example/api/v4", transport=f), "123", config(), AT) + assert any(r["status"] == code for r in i["receipts"]) + assert item(project(i, config(), empty_evidence()), "finish_merge")["merge_gate"]["status"] != "READY" + if suffix == "/issues/2": + assert item(project(i, config(), empty_evidence()), "implement_issue")["eligibility"] == "UNKNOWN" + + +def test_work_gitlab_diff_result_cap_and_train_membership(): + routes = gitlab_routes(); routes["/api/v4/projects/123/merge_requests/1"][2]["changes_count"] = "1000+" + routes["/api/v4/projects/123/merge_trains"] = (200, {"x-next-page": ""}, [{"id": 5, "merge_request": {"iid": 1}}]) + i = collect(Client("gitlab", "https://gitlab.example/api/v4", transport=Routes(routes)), "123", config(), AT) + c = i["changes"]["items"][0] + assert c["files"]["status"] == "PARTIAL" and c["merge_train"] is True + + +def test_work_gitlab_head_moving_during_collection(): + routes = gitlab_routes(); initial = routes["/api/v4/projects/123/merge_requests/1"][2] + counter = 0 + def moving(calls): + nonlocal counter + counter += 1 + return 200, {}, initial if counter == 1 else {**initial, "sha": SHA} + routes["/api/v4/projects/123/merge_requests/1"] = moving + i = collect(Client("gitlab", "https://gitlab.example/api/v4", transport=Routes(routes)), "123", config(), AT) + c = i["changes"]["items"][0] + assert c["checks"]["status"] == c["threads"]["status"] == "UNAVAILABLE" + + +def test_work_selected_refresh_does_not_discover_whole_repository(): + routes = gitlab_routes(); selected = item(project(inv(), config(), empty_evidence()), "implement_issue") + f = Routes(routes) + i = collect(Client("gitlab", "https://gitlab.example/api/v4", transport=f), "123", config(), AT, selected=selected) + assert i["selection"]["changes"] == "SELECTED" + assert not any(urlsplit(c[1]).path.endswith("/merge_requests") for c in f.calls) + assert len(f.calls) == 3 + + +def test_work_canonical_collection_refuses_wrong_revision(): + with pytest.raises(ScopeError, match="canonical"): + collect(Client("gitlab", "https://gitlab.example/api/v4", transport=Routes(gitlab_routes())), + "123", config(), AT, revision=HEAD) + + +@pytest.mark.parametrize("target", ["http://api.github.com/x", "https://evil.test/x"]) +def test_work_redirect_cannot_forward_credentials(target): + with pytest.raises(ScopeError, match="redirect"): + SafeRedirect().redirect_request(Request("https://api.github.com/x", headers={"Authorization": "Bearer secret"}), + None, 302, "", {}, target) + + +@pytest.mark.parametrize("method,url,body", [ + ("DELETE", "https://api.github.com/repos/a/b", None), + ("POST", "https://api.github.com/repos/a/b/issues", b'{"query":"query($n:Int!){x}"}'), + ("POST", "https://api.github.com/graphql", b'{"query":"mutation{deleteSomething}"}'), +]) +def test_work_transport_refuses_mutation(method, url, body): + with pytest.raises(ScopeError, match="read"): + HTTPTransport().request(method, url, {}, body, 1) + + +def test_work_conditional_304_replays_body_without_credential_crossing(tmp_path): + f = Routes({"/x": (200, {"etag": "e1"}, [{"id": 1}])}) + c = Client("github", "https://api.github.com", "a", transport=f, cache=tmp_path) + c.get("/x"); f.routes["/x"] = (304, {}, None) + assert c.get("/x")[0] == [{"id": 1}] + assert c.receipts[0] == c.receipts[1] + assert c.telemetry[0]["status"] == 200 and c.telemetry[1]["status"] == 304 + assert f.calls[-1][2]["If-None-Match"] == "e1" + other = Client("github", "https://api.github.com", "b", transport=f, cache=tmp_path) + with pytest.raises(ScopeError, match="304"): + other.get("/x") + + +def test_work_enterprise_graphql_uses_api_graphql(): + f = Routes({"/api/graphql": (200, {}, {})}) + c = Client("github", "https://github.example/api/v3", transport=f) + c.get("/graphql", query={"query": "query($x:Int!){x}", "variables": {"x": 1}}) + assert f.calls[0][1] == "https://github.example/api/graphql" + + +@pytest.mark.parametrize("change", [ + lambda i: i["subject"].update(project_id="999"), + lambda i: i["subject"].update(context="CANDIDATE"), +]) +def test_work_consumer_checks_project_and_context(change): + decoded = verify_content_fixture(); i = inv(); change(i) + with pytest.raises(ScopeError, match="mismatch"): + recheck(decoded, i, AT, actor="owner", policy_version="example-1", expected_project=identity(inv()["subject"])) + + +def verify_content_fixture(): + from devostasis.workscope.bundle import verify_content + return verify_content(build(inv(), config(), empty_evidence())[1]) + + +def test_work_consumer_refuses_stale_scope_actor_and_policy(): + d = verify_content_fixture() + with pytest.raises(ScopeError, match="expired"): + recheck(d, inv(), "2026-10-02T14:00:00Z", actor="owner", policy_version="example-1", expected_project=identity(inv()["subject"])) + with pytest.raises(ScopeError, match="actor"): + recheck(d, inv(), AT, actor="other", policy_version="example-1", expected_project=identity(inv()["subject"])) + with pytest.raises(ScopeError, match="policy"): + recheck(d, inv(), AT, actor="owner", policy_version="other", expected_project=identity(inv()["subject"])) + + +def test_work_policy_reuse_changes_digest(tmp_path, capsys): + bundle = publish(tmp_path / "store", build(inv(), config(), empty_evidence())[1]) + p = config(); p["required_checks"] = [] + (tmp_path / "policy.json").write_bytes(canonical_bytes(p)) + (tmp_path / "inventory.json").write_bytes(canonical_bytes(inv())) + selected = item(verify(bundle)["scope.json"], "finish_merge") + assert main(["work", "recheck", "--bundle", str(bundle), "--policy", str(tmp_path / "policy.json"), + "--inventory", str(tmp_path / "inventory.json"), "--item", selected["id"], "--at", AT, + "--actor", "owner", "--policy-version", "example-1", "--expected-project-id", "123"]) == 2 + assert "digest mismatch" in capsys.readouterr().err + + +def test_work_future_evidence_and_duplicate_inventory_refused(): + i = inv(); i["changes"]["items"][0]["checks"]["items"][0]["at"] = "2026-10-02T12:00:00.000001Z" + with pytest.raises(ScopeError, match="after"): + inventory(i) + i = inv(); i["issues"]["items"].append(deepcopy(i["issues"]["items"][0])) + with pytest.raises(ScopeError, match="duplicate"): + inventory(i) + + +def test_work_satisfied_review_gate_excludes_unrequested_extra_review(): + i = inv(); i["changes"]["items"][0]["reviewers"] = [] + s = project(i, config("executor"), empty_evidence()) + assert not s["queues"]["review"] + assert any(e["reason"] == "INDEPENDENT_REVIEW_REQUIREMENT_SATISFIED" for e in s["excluded"]) + i["changes"]["items"][0]["reviewers"] = ["executor"] + assert project(i, config("executor"), empty_evidence())["queues"]["review"] + + +def test_work_repository_policy_is_admissible_and_versioned(): + from pathlib import Path + value = json.loads((Path(__file__).resolve().parents[1] / ".devostasis/work-policy.json").read_text()) + assert policy(value)["version"] == "devostasis-work-1" + assert len(value["required_checks"]) == 3 + + +def test_work_optional_cache_failure_keeps_admitted_source_evidence(tmp_path, monkeypatch): + def fail(*args): + raise OSError("cache disk unavailable") + monkeypatch.setattr("devostasis.workscope.collect.atomic", fail) + f = Routes({"/x": (200, {"etag": "e1"}, [{"id": 1}])}) + c = Client("github", "https://api.github.com", transport=f, cache=tmp_path) + assert c.get("/x")[0] == [{"id": 1}] + assert c.receipts[0]["status"] == 200 and c.telemetry[0]["cache_write_failed"] + + +def test_work_transport_bounds_slow_stream_and_bytes(monkeypatch): + class Response: + code, headers = 200, {} + def __enter__(self): + return self + def __exit__(self, *args): + pass + def read1(self, size): + return b"x" + class Opener: + def open(self, *args, **kwargs): + return Response() + t = HTTPTransport(); t.opener = Opener() + clock = iter([0, 0, 2]) + monkeypatch.setattr("devostasis.workscope.collect.time.monotonic", lambda: next(clock)) + with pytest.raises(ScopeError, match="TIME_CAP"): + t.request("GET", "https://api.github.com/x", {}, None, 1) + monkeypatch.setattr("devostasis.workscope.collect.time.monotonic", lambda: 0) + monkeypatch.setattr("devostasis.workscope.collect.MAX_RESPONSE", 1) + with pytest.raises(ScopeError, match="BYTE_CAP"): + t.request("GET", "https://api.github.com/x", {}, None, 1) + + +def test_work_unknown_dependency_and_unbounded_criterion_offer_recovery(): + p = config(); p["criteria"][0].update(paths=[], dependencies=["3"]) + s = project(inv(), p, empty_evidence()) + assert item(s, "implement_issue")["eligibility"] == "UNKNOWN" + assert any(i["source"] == "recovery:issue:3" for i in s["items"]) + assert any(i["source"] == "recovery:issue:2/C1" for i in s["items"]) From 13e9243933694de9ffe3290df64b27abe65e652b Mon Sep 17 00:00:00 2001 From: Aleksei Bogun Date: Fri, 2 Oct 2026 18:04:31 +0300 Subject: [PATCH 15/33] Require fresh candidate content evidence before live recheck --- .devostasis/debt.json | 8 ++++++++ CHANGELOG.md | 3 ++- ROADMAP.md | 4 ++++ docs/spec/work-scope.md | 7 +++++++ docs/work-scopes.md | 7 +++++++ src/devostasis/workscope/cli.py | 2 ++ tests/test_workscope_adapters.py | 13 +++++++++++++ 7 files changed, 43 insertions(+), 1 deletion(-) diff --git a/.devostasis/debt.json b/.devostasis/debt.json index 9aa596f..44b86b3 100644 --- a/.devostasis/debt.json +++ b/.devostasis/debt.json @@ -2,6 +2,14 @@ "schema": "devostasis.debt.v1", "notes": "Registered maintenance obligations of this repository, read by Devostasis when debt.source = file. An item belongs here when the current code carries a cost, not when a feature is missing: planned work lives in targets.json and ROADMAP.md. 'opened' is the date the obligation started being carried, 'updated' the last time it changed; an open item untouched for 30 days is stale.", "items": [ + { + "id": "D-7", + "title": "Live candidate content recheck needs an explicit mutable source-ref binding", + "state": "open", + "opened": "2026-10-02", + "updated": "2026-10-02", + "cost": "Candidate subject identity binds an immutable commit but not a mutable branch/MR content-source ref. The CLI therefore requires a freshly attested inventory for candidate issue/research/analysis recheck; it cannot reuse a historical SHA as proof that a branch stayed current. Named source-ref support is tracked in https://github.com/drevendev/Devostasis/issues/60. Canonical default-branch tasks and candidate PR head recheck are unaffected." + }, { "id": "D-6", "title": "GitLab work scopes cannot certify exact-head approvals from the aggregate approvals API", diff --git a/CHANGELOG.md b/CHANGELOG.md index f122c4b..e0f3db4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,8 @@ Evidence to Action, target E1: a separate deterministic consumer companion slices have scope-bound cursors, overflow and explicit exclusion reasons. - `devostasis work policy/import/collect/build/run/verify/replay/slice/explain/recheck`. Recheck binds consumer policy and refreshes only selected sources before - returning eligibility; no command claims or executes work. + returning eligibility; candidate content tasks without a mutable ref require + a fresh attested inventory (follow-up #60). No command claims or executes work. - GitHub reusable read-only workflow with optional protected durable history runner, GitLab canonical/candidate CI recipes, adopter guide, public frozen example and implementation-owned regression cases. The public GitHub live diff --git a/ROADMAP.md b/ROADMAP.md index 65e8e3a..1e249cc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,6 +18,10 @@ belongs to the independently integrating GitLab project. This implements a companion consumer contour, not the core accepted Phase C Instrument carrier or the unaccepted Evidence Observatory composition. +Automatic candidate content recheck also needs a named mutable source binding +([#60](https://github.com/drevendev/Devostasis/issues/60)); v1 refuses to infer +freshness from a historical SHA and requires a fresh attested inventory there. + This is an unreleased increment. E1 being implemented does not close B1, B7, the earlier target `v0.3` (the deferred Phase B/adoption completion bar), or v1.0. Their permanent ids and original acceptance are preserved. Review and diff --git a/docs/spec/work-scope.md b/docs/spec/work-scope.md index e46c169..1dec931 100644 --- a/docs/spec/work-scope.md +++ b/docs/spec/work-scope.md @@ -146,6 +146,13 @@ dispatches, labels, comments, closes, merges or deploys. Source state can change after recheck; the execution client must condition its mutation on the exact revision and enforce authority immediately before acting. +CANDIDATE content-only proposals have an immutable content id but no recorded +mutable branch ref in v1. Live CLI recheck for issue/research/analysis/recovery +therefore requires a freshly attested input inventory from the execution +client; it refuses automatic historical-SHA reuse. Selected PR head recheck +and CANONICAL default-branch content have live bindings. Named candidate ref +support is tracked in #60. + ## Executable evidence Implementation-owned regression cases are in `tests/test_workscope.py` and diff --git a/docs/work-scopes.md b/docs/work-scopes.md index efcad8a..6d99d51 100644 --- a/docs/work-scopes.md +++ b/docs/work-scopes.md @@ -66,6 +66,13 @@ exact-head mutations in the execution client. New checks, claims, dependencies or actions may require a rebuilt scope. For offline negative cases supply `--inventory` and an explicit `--at`. +For CANDIDATE issue/research/analysis proposals, supply a freshly attested +`--inventory` from the execution client's current source/checkout. A fixed +candidate commit does not identify a mutable source branch, so the CLI refuses +to infer that it is still current. Automatic named-ref refresh is tracked in +[#60](https://github.com/drevendev/Devostasis/issues/60). Canonical default-branch +content and selected candidate PR head recheck already have live source bindings. + [examples/work/github-observe.yml](../examples/work/github-observe.yml) calls the reusable workflow after release. It runs after pushes, periodically and for candidate PRs. It is non-gating, uses read credentials and uploads failure diff --git a/src/devostasis/workscope/cli.py b/src/devostasis/workscope/cli.py index b32dd5a..1018d94 100644 --- a/src/devostasis/workscope/cli.py +++ b/src/devostasis/workscope/cli.py @@ -104,6 +104,8 @@ def cmd(args): ("https://api.github.com" if args.provider == "github" else "https://gitlab.com/api/v4"), "project_id": args.expected_project_id} require(identity(scope["subject"]) == expected, "consumer expected project mismatch") + require(scope["subject"]["context"] != "CANDIDATE" or item["source"].startswith("change:") or args.inventory, + "candidate content recheck requires a freshly attested --inventory; no mutable source ref was recorded") current = read_json(args.inventory) if args.inventory else collect( client(args, scope["subject"]["provider"], scope["subject"]["endpoint"]), scope["subject"]["locator"], decoded["policy.json"], args.at, context=scope["subject"]["context"], diff --git a/tests/test_workscope_adapters.py b/tests/test_workscope_adapters.py index 93b2c84..71b9b7d 100644 --- a/tests/test_workscope_adapters.py +++ b/tests/test_workscope_adapters.py @@ -243,3 +243,16 @@ def test_work_unknown_dependency_and_unbounded_criterion_offer_recovery(): assert item(s, "implement_issue")["eligibility"] == "UNKNOWN" assert any(i["source"] == "recovery:issue:3" for i in s["items"]) assert any(i["source"] == "recovery:issue:2/C1" for i in s["items"]) + + +def test_work_candidate_content_cannot_recheck_an_unrecorded_mutable_ref(tmp_path, capsys, monkeypatch): + i = inv(); i["subject"]["context"] = "CANDIDATE" + bundle = publish(tmp_path / "store", build(i, config(), empty_evidence())[1]) + p = tmp_path / "policy.json"; p.write_bytes(canonical_bytes(config())) + selected = item(verify(bundle)["scope.json"], "implement_issue") + def forbidden(*args, **kwargs): + raise AssertionError("must reject before making a network request") + monkeypatch.setattr("devostasis.workscope.cli.collect", forbidden) + assert main(["work", "recheck", "--bundle", str(bundle), "--policy", str(p), "--item", selected["id"], + "--expected-project-id", "123", "--actor", "owner", "--policy-version", "example-1", "--at", AT]) == 2 + assert "freshly attested" in capsys.readouterr().err From d7ff2be63a3a45c1a657aa9c4a11c80f19f04e53 Mon Sep 17 00:00:00 2001 From: Aleksei Bogun Date: Tue, 6 Oct 2026 13:21:46 +0300 Subject: [PATCH 16/33] Preserve missing work evidence and reconcile release readiness --- CHANGELOG.md | 11 ++++ README.md | 32 ++++++++--- ROADMAP.md | 87 ++++++++++++++++++++++++----- docs/spec/work-scope.md | 4 ++ docs/work-scopes.md | 4 ++ src/devostasis/workscope/collect.py | 5 +- tests/test_workscope_adapters.py | 47 ++++++++++++++++ 7 files changed, 169 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0f3db4..5628758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ contract or a policy always come with a version bump of that contract. ## 0.3.0 (unreleased) +Release-readiness review, 2026-10-06: + +- GitHub/GitLab work issue collectors reject unknown lifecycle states instead + of treating them as CLOSED and silently completing a consumer criterion. +- A mix of available and missing selected issues remains PARTIAL regardless + of read order. Previously a missing issue read first left an UNAVAILABLE + collection containing later readable records and failed the entire scope. + Readable work and bounded evidence recovery now survive together. +- README and roadmap distinguish the published 0.1.9 engine, the pending + 0.2.0/0.3.0 releases and the proposed external-adoption increment. + Evidence to Action, target E1: a separate deterministic consumer companion (`devostasis.work.v1`, replay engine `devostasis.work-engine.v1`). diff --git a/README.md b/README.md index b6eb6ad..3024cce 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,11 @@ The name is *development* plus *homeostasis*: the goal is a stable, honest reading of where a project actually stands, so that people and autonomous development systems can react to it. +The latest published tag is **v0.1.9**. This checkout prepares **0.3.0**, +including the pending 0.2.0 adoption fixes; neither increment is released yet. +Use the checkout installation below to try the new companion before its tag +is published. See [ROADMAP.md](ROADMAP.md) for release gates and next steps. + The **0.3.0 Evidence to Action** companion adds five bounded work queues: `review`, `finish_merge`, `implement_issue`, `research`, `analyze_code`. It binds tasks to exact revisions, explicit consumer priorities, dependencies, @@ -176,6 +181,14 @@ comes first is the consumer's policy, and no accepted contract defines it. Requires Python 3.12 or newer. The runtime uses the standard library only. +For this unreleased checkout: + +```bash +pip install -e . +``` + +After the 0.3.0 release tag is published: + ```bash pip install git+https://github.com/drevendev/devostasis@v0.3.0 ``` @@ -289,13 +302,18 @@ A complete synthetic bundle is checked in under ## Status -Version 0.1.x is the minimum viable engine: GitHub only, seven Vitals, -immutable bundles, a filesystem history store with a fleet index, a Markdown -report, the demand interface, gauges, the accepted band ordering and an -executable conformance vector format. The [ROADMAP](ROADMAP.md) lists what is -deliberately deferred (a GitLab adapter, additional instruments such as test -state, coverage and deployments, an HTML renderer, a calibration corpus) and -which accepted research judgements are still waiting to be adopted. The +The published 0.1.9 engine supplies seven Vitals, GitHub observation, +immutable bundles, filesystem history, fleet data, Markdown reports, demand, +gauges, accepted band ordering and executable conformance vectors. The +pending 0.2.0 adoption adds the accepted Vital repairs and durable Integrity +history; this 0.3.0 branch adds the separate Evidence to Action companion. +Its GitLab work collector does not yet implement the core GitLab Vital +adapter or the accepted Instruments carrier. + +Phase A is closed. Phase B has five of seven items implemented on this +branch; complete executable conformance (B1) and an outside adopter (B7) +remain open. The [ROADMAP](ROADMAP.md) names the release sequence, +compatibility/audit obligations and external-adoption gates. The [CHANGELOG](CHANGELOG.md) records every contract and policy version change. ## Provenance diff --git a/ROADMAP.md b/ROADMAP.md index 1e249cc..9996a7e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,25 @@ 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 +## Release state and next steps — 2026-10-06 + +The repository is at the **release-readiness and external-adoption stage**: +the observation engine is released, the work companion is implemented, and +Phase B is still open. The audit started with a clean working tree. Every +outstanding development branch already has a pull request: + +| Work | Pull request | Integration state | +| --- | --- | --- | +| 0.2.0 accepted Vital/history adoption | [#53](https://github.com/drevendev/Devostasis/pull/53) | Open; targets `master` | +| 0.2.0 history admission/readiness repair | [#57](https://github.com/drevendev/Devostasis/pull/57) | Open; targets `release/0.2.0`, before #53 | +| 0.3.0 Evidence to Action | [#59](https://github.com/drevendev/Devostasis/pull/59) | Open; includes the original commits of #53 and #57 | +| Dependabot, CI concurrency, Hungry Crab state | [#36](https://github.com/drevendev/Devostasis/pull/36), [#37](https://github.com/drevendev/Devostasis/pull/37), [#50](https://github.com/drevendev/Devostasis/pull/50) | Original commits included in #57 and #59; both attribution receipts/notices retained | + +The release heads are mergeable. Fork CI passed for the prepared heads on +Python 3.12/3.13/3.14; upstream runs for #57/#59 require maintainer approval, +and neither PR has an independent review yet. A later push needs fresh CI +for its own head. Inclusion in a release branch is not an upstream merge: +all six PRs remain open and neither `v0.2.0` nor `v0.3.0` exists. **0.3.0 Evidence to Action is implemented on this release branch**, with its own consumer contract and target E1. The companion supplies five queues, @@ -40,10 +58,15 @@ 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. +1. **Review and release the prepared increments.** #57 already binds carried + history to the verified predecessor, rejects duplicate carried revision + ids and integrates the pending maintenance. The review of 2026-10-06 also + repairs unknown issue-state coercion and mixed-availability collection in + the 0.3.0 work collector. The release sequence is #57 into `release/0.2.0`, + #53 into `master` and tag `v0.2.0`, then #59 into `master` and tag `v0.3.0`. + Approve upstream CI and review each final head first. Retire the superseded + maintenance PRs when their commits land through the release; do not apply + them again independently. 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 @@ -53,13 +76,7 @@ The next work is ordered by the evidence each step makes possible: 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. **Adopt the implemented bounded work companion.** The consumer +3. **Adopt the implemented bounded work companion.** The 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 @@ -72,8 +89,14 @@ The next work is ordered by the evidence each step makes possible: accepted core Vital/Instrument semantics; task selection remains outside the observational engine. Adopter policy, sustained external deployment and the revision-bound GitLab approval capability are the remaining steps. +4. **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. -The major product increment in this branch is **Evidence to Action**: +The product increment already implemented in this branch 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 @@ -82,6 +105,44 @@ 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. +## Recommended next major increment: external adoption of Evidence to Action + +Treat this as a proposed next increment, not a newly accepted research unit +or a promise of a release date. The largest product gain now comes from an +outside consumer relying on the implemented scopes over time. More surface +before that feedback cannot establish whether the current handoff is useful. + +Deliver it in this order: + +1. Publish the prepared releases, restore the private fleet observer (#32) + and choose one outside repository for B7. Public self-observation succeeded + on 2026-10-05, but it is a different workflow; the latest public statement + about the private fleet is still its 2026-09-30 billing failure. Fresh + private history must be checked directly before it is called restored. +2. Adopt the accepted compatibility policy (#34) and work the outstanding + timestamp, lineage and store audit families (#35), including the explicit + core receipt-identity adoption decision (#19). Give stored versions + exact verification/replay dispatch and keep old bundles verifiable. + Complete the remaining executable conformance through B1/#23; keep the + public gap count explicit until accepted fixtures actually run. +3. Run an outside developer/agent against verified scopes with a declared + policy, bounded reads, acceptance and live recheck. Add the named candidate + source binding (#60) and validate GitLab revision-bound approvals (#58) + where the consumer needs them. Record whether tasks were completed, what + evidence was missing and whether recheck correctly invalidated moved work. +4. After the Phase B and compatibility gates, expand the same consumer path + with the core GitLab adapter and accepted Instruments carrier. Prioritize + TestState/Coverage/Deployment/Work from observed adopter needs; the existing + companion report profiles do not close those core contracts. + +Acceptance: an outside repository produces sustained verifiable history; +its consumer selects and completes a task from the declared scope without +rediscovering the whole repository; missing evidence yields bounded recovery; +changed revisions invalidate stale work; historical bundles still replay. +Keep the observer read-only and execution/merge/deploy authority in that +outside client. An HTML Explorer, renderer themes and PyPI distribution +remain later reach/presentation work, subject to their existing gates. + ## How this roadmap is worked ```text diff --git a/docs/spec/work-scope.md b/docs/spec/work-scope.md index 1dec931..b69d00a 100644 --- a/docs/spec/work-scope.md +++ b/docs/spec/work-scope.md @@ -28,6 +28,10 @@ COMPLETE means empty **within the declared selection**. Issues are read from the explicit criteria/dependency register, not the whole issue tracker. `selection.issues` names those ids; `selection.changes` distinguishes `OPEN_AND_REGISTERED` from the narrow `SELECTED` refresh used by recheck. +Unavailable or malformed issue records stay missing evidence, including an +unknown provider lifecycle state; they never become CLOSED. A selection +containing both readable and missing issues is PARTIAL regardless of read +order, retaining readable records and bounded recovery for missing ids. Discovery, collection budgets and source authentication do not make this a cryptographically signed statement about a forge. Consumers trust their collector and transport; hashes bind stored inputs and deterministic replay. diff --git a/docs/work-scopes.md b/docs/work-scopes.md index 6d99d51..6a159c5 100644 --- a/docs/work-scopes.md +++ b/docs/work-scopes.md @@ -1,5 +1,9 @@ # Evidence to Action in 0.3.0 +0.3.0 is prepared on the release branch and is not tagged yet. Install the +checkout for the offline walkthrough; the pinned workflow examples become +available after release. The published engine remains v0.1.9. + The companion turns recorded work and findings into five bounded queues. It can collect GitHub or GitLab work context; it does not replace the core Vital adapter or implement the accepted Instruments carrier. The schemas are diff --git a/src/devostasis/workscope/collect.py b/src/devostasis/workscope/collect.py index a81c10b..9fba682 100644 --- a/src/devostasis/workscope/collect.py +++ b/src/devostasis/workscope/collect.py @@ -195,11 +195,13 @@ def _owners(value): def _gh_issue(i): + require(i["state"] in ("open", "closed"), "ISSUE_STATE_UNKNOWN") return {"id": str(i["number"]), "title": i["title"], "state": "OPEN" if i["state"] == "open" else "CLOSED", "owners": _owners(i), "updated_at": timestamp(i["updated_at"])} def _gl_issue(i): + require(i["state"] in ("opened", "closed"), "ISSUE_STATE_UNKNOWN") return {"id": str(i["iid"]), "title": i["title"], "state": "OPEN" if i["state"] == "opened" else "CLOSED", "owners": _owners(i), "updated_at": timestamp(i["updated_at"])} @@ -367,8 +369,9 @@ def collect(client, locator, config, at=None, revision=None, context="CANONICAL" require(raw is not None and "pull_request" not in raw, failure or "ISSUE_UNAVAILABLE") issues["items"].append(issue_fn(raw)) except (ScopeError, KeyError, TypeError, ValueError): - issues["status"] = "PARTIAL" if issues["items"] else "UNAVAILABLE" issues["reasons"] = ["ISSUE_OR_DEPENDENCY_UNAVAILABLE"] + if issues["reasons"]: + issues["status"] = "PARTIAL" if issues["items"] else "UNAVAILABLE" now = timestamp(at or datetime.now(timezone.utc).isoformat()) result = {"contract": CONTRACT, "kind": "inventory", "subject": project, "observed_at": now, "changes": changes, "issues": issues, "receipts": client.receipts, diff --git a/tests/test_workscope_adapters.py b/tests/test_workscope_adapters.py index 71b9b7d..c52b3fe 100644 --- a/tests/test_workscope_adapters.py +++ b/tests/test_workscope_adapters.py @@ -45,6 +45,53 @@ def gitlab_routes(): root + "/issues/2": (200, {}, {"iid": 2, "title": "Feature", "state": "opened", "assignees": [], "updated_at": AT})} +def issue_routes(provider): + if provider == "gitlab": + return gitlab_routes(), "https://gitlab.example/api/v4", "123", "/api/v4/projects/123" + root = "/repos/acme/widget" + return {root: (200, {}, {"id": 123, "full_name": "acme/widget", "default_branch": "main"}), + root + "/commits/main": (200, {}, {"sha": SHA}), + root + "/issues/2": (200, {}, {"number": 2, "title": "Feature", "state": "open", + "assignees": [], "updated_at": AT})}, "https://api.github.com", "acme/widget", root + + +@pytest.mark.parametrize("provider", ["github", "gitlab"]) +@pytest.mark.parametrize("state", [None, "", "unexpected", False, 0, [], {}]) +def test_work_unknown_issue_state_cannot_complete_a_criterion(provider, state): + routes, endpoint, locator, root = issue_routes(provider) + routes[root + "/issues/2"][2]["state"] = state + p = config() + selected = item(project(inv(), p, empty_evidence()), "implement_issue") + i = collect(Client(provider, endpoint, transport=Routes(routes)), locator, p, AT, selected=selected) + assert i["issues"]["status"] == "UNAVAILABLE" and not i["issues"]["items"] + scope = project(i, p, empty_evidence()) + assert item(scope, "implement_issue")["eligibility"] == "UNKNOWN" + assert any(task["source"] == "recovery:issue:2" for task in scope["items"]) + assert not any(exclusion["reason"] == "CRITERION_DONE" for exclusion in scope["excluded"]) + + +@pytest.mark.parametrize("provider", ["github", "gitlab"]) +@pytest.mark.parametrize("missing", ["2", "3"]) +def test_work_mixed_issue_availability_preserves_partial_scope_in_either_order(provider, missing): + routes, endpoint, locator, root = issue_routes(provider) + valid = deepcopy(routes[root + "/issues/2"][2]) + valid["number" if provider == "github" else "iid"] = 3 + routes[root + "/issues/3"] = (200, {}, valid) + routes[root + "/issues/" + missing] = (403, {}, {}) + p = config(); p["criteria"][0]["dependencies"] = ["3"] + selected = item(project(inv(), p, empty_evidence()), "implement_issue") + i = collect(Client(provider, endpoint, transport=Routes(routes)), locator, p, AT, selected=selected) + available = "3" if missing == "2" else "2" + assert i["issues"]["status"] == "PARTIAL" + assert [record["id"] for record in i["issues"]["items"]] == [available] + scope = project(i, p, empty_evidence()) + assert item(scope, "implement_issue")["eligibility"] == "UNKNOWN" + assert any(task["source"] == "recovery:issue:" + missing for task in scope["items"]) + assert any(task["source"] == "request:Q1" and task["eligibility"] == "READY" for task in scope["items"]) + from devostasis.workscope.bundle import verify_content + assert verify_content(build(i, p, empty_evidence())[1])["inventory.json"] == i + + def test_work_gitlab_manual_jobs_diffs_threads_and_exact_head_unknown(): f = Routes(gitlab_routes()) i = collect(Client("gitlab", "https://gitlab.example/api/v4", "read-only", transport=f), "123", config(), AT) From 4d96120b708c7e27f71e29d55826b168da3b464f Mon Sep 17 00:00:00 2001 From: Aleksei Bogun Date: Tue, 6 Oct 2026 14:46:22 +0300 Subject: [PATCH 17/33] Release 0.4.0: reproducible consumer handoff and exact compatibility --- .github/workflows/observe-self.yml | 4 +- .github/workflows/work-observe.yml | 14 +- CHANGELOG.md | 34 + README.md | 19 +- ROADMAP.md | 21 + docs/deployment.md | 6 +- docs/external-adoption.md | 62 + docs/spec/PROVENANCE.md | 3 + docs/spec/README.md | 3 + docs/spec/bundle.md | 13 +- docs/spec/compatibility.md | 48 + docs/spec/conformance.md | 22 +- docs/spec/observations.md | 5 + docs/spec/receipt-identity.md | 48 + docs/spec/work-handoff.md | 44 + docs/work-scopes.md | 10 +- examples/legacy/bundle-v2/activity.json | 317 +++ examples/legacy/bundle-v2/delta.json | 103 + examples/legacy/bundle-v2/demand.json | 104 + .../legacy/bundle-v2/effective-config.json | 1 + examples/legacy/bundle-v2/gauges.json | 72 + examples/legacy/bundle-v2/manifest.json | 190 ++ examples/legacy/bundle-v2/observations.json | 1795 +++++++++++++++++ examples/legacy/bundle-v2/report.md | 224 ++ examples/legacy/bundle-v2/snapshot.json | 643 ++++++ examples/legacy/work-v1/evidence.json | 1 + examples/legacy/work-v1/inventory.json | 1 + examples/legacy/work-v1/manifest.json | 1 + examples/legacy/work-v1/policy.json | 1 + examples/legacy/work-v1/report.md | 17 + examples/legacy/work-v1/scope.json | 1 + examples/observations.json | 50 +- examples/sample-bundle/manifest.json | 91 +- examples/sample-bundle/observations.json | 50 +- examples/sample-bundle/report.md | 6 +- examples/sample-bundle/snapshot.json | 2 +- examples/work/bundle/evidence.json | 2 +- examples/work/bundle/inventory.json | 2 +- examples/work/bundle/manifest.json | 2 +- examples/work/bundle/policy.json | 2 +- examples/work/bundle/scope.json | 2 +- examples/work/evidence.json | 2 +- examples/work/github-observe.yml | 4 +- examples/work/gitlab-observe.yml | 17 +- examples/work/inventory.json | 2 +- examples/work/policy.json | 2 +- pyproject.toml | 2 +- schemas/execution-receipt.schema.json | 56 + schemas/manifest.schema.json | 63 +- schemas/manifest.v1.schema.json | 228 +++ schemas/manifest.v2.schema.json | 372 ++++ schemas/observations.schema.json | 60 +- schemas/observations.v1.schema.json | 162 ++ schemas/observations.v2.schema.json | 81 + schemas/receipt-identity.schema.json | 93 + scripts/make_examples.py | 2 + scripts/make_work_examples.py | 6 + src/devostasis/__init__.py | 2 +- src/devostasis/activity.py | 2 +- src/devostasis/bundle.py | 98 +- src/devostasis/cli.py | 23 +- src/devostasis/compatibility.py | 139 ++ src/devostasis/contracts.py | 13 +- src/devostasis/lineages.py | 55 + src/devostasis/observations.py | 117 +- src/devostasis/render.py | 2 +- src/devostasis/runner.py | 19 +- src/devostasis/workscope/__init__.py | 4 +- src/devostasis/workscope/binding.py | 74 + src/devostasis/workscope/bundle.py | 10 +- src/devostasis/workscope/cli.py | 59 +- src/devostasis/workscope/collect.py | 53 +- src/devostasis/workscope/evidence.py | 4 +- src/devostasis/workscope/glab.py | 60 + src/devostasis/workscope/handoff.py | 149 ++ src/devostasis/workscope/model.py | 34 +- src/devostasis/workscope/planner.py | 5 +- tests/test_bundle_history.py | 2 +- tests/test_cli.py | 4 +- tests/test_compatibility.py | 114 ++ tests/test_glab_bridge.py | 64 + tests/test_rate_limits.py | 15 +- tests/test_receipt_identity.py | 137 ++ tests/test_successor_schema.py | 23 + tests/test_work_handoff.py | 159 ++ 85 files changed, 6384 insertions(+), 244 deletions(-) create mode 100644 docs/external-adoption.md create mode 100644 docs/spec/compatibility.md create mode 100644 docs/spec/receipt-identity.md create mode 100644 docs/spec/work-handoff.md create mode 100644 examples/legacy/bundle-v2/activity.json create mode 100644 examples/legacy/bundle-v2/delta.json create mode 100644 examples/legacy/bundle-v2/demand.json create mode 100644 examples/legacy/bundle-v2/effective-config.json create mode 100644 examples/legacy/bundle-v2/gauges.json create mode 100644 examples/legacy/bundle-v2/manifest.json create mode 100644 examples/legacy/bundle-v2/observations.json create mode 100644 examples/legacy/bundle-v2/report.md create mode 100644 examples/legacy/bundle-v2/snapshot.json create mode 100644 examples/legacy/work-v1/evidence.json create mode 100644 examples/legacy/work-v1/inventory.json create mode 100644 examples/legacy/work-v1/manifest.json create mode 100644 examples/legacy/work-v1/policy.json create mode 100644 examples/legacy/work-v1/report.md create mode 100644 examples/legacy/work-v1/scope.json create mode 100644 schemas/execution-receipt.schema.json create mode 100644 schemas/manifest.v1.schema.json create mode 100644 schemas/manifest.v2.schema.json create mode 100644 schemas/observations.v1.schema.json create mode 100644 schemas/observations.v2.schema.json create mode 100644 schemas/receipt-identity.schema.json create mode 100644 src/devostasis/compatibility.py create mode 100644 src/devostasis/lineages.py create mode 100644 src/devostasis/workscope/binding.py create mode 100644 src/devostasis/workscope/glab.py create mode 100644 src/devostasis/workscope/handoff.py create mode 100644 tests/test_compatibility.py create mode 100644 tests/test_glab_bridge.py create mode 100644 tests/test_receipt_identity.py create mode 100644 tests/test_successor_schema.py create mode 100644 tests/test_work_handoff.py diff --git a/.github/workflows/observe-self.yml b/.github/workflows/observe-self.yml index 856218d..1f962b8 100644 --- a/.github/workflows/observe-self.yml +++ b/.github/workflows/observe-self.yml @@ -4,7 +4,7 @@ # # jobs: # vitals: -# uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.3.0 +# uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.4.0 # with: # debt-labels: "type:debt" # @@ -24,7 +24,7 @@ on: devostasis-ref: description: Git ref of Devostasis to install (tag, branch or commit) type: string - default: v0.3.0 + default: v0.4.0 planning-source: description: milestones, file or none type: string diff --git a/.github/workflows/work-observe.yml b/.github/workflows/work-observe.yml index a7ad399..2889241 100644 --- a/.github/workflows/work-observe.yml +++ b/.github/workflows/work-observe.yml @@ -5,7 +5,7 @@ on: inputs: devostasis-ref: type: string - default: v0.3.0 + default: v0.4.0 policy-path: type: string default: .devostasis/work-policy.json @@ -61,12 +61,22 @@ jobs: MAX_REQUESTS: ${{ inputs.max-requests }} MAX_PAGES: ${{ inputs.max-pages }} SECONDS_BUDGET: ${{ inputs.seconds }} + SOURCE_CHANGE: ${{ github.event.pull_request.number }} + SOURCE_BRANCH: ${{ github.ref_name }} run: | mkdir -p work-output context=CANDIDATE if [ "$IS_CANONICAL" = "true" ]; then context=CANONICAL; fi + set -- + if [ "$context" = CANDIDATE ]; then + if [ -n "$SOURCE_CHANGE" ]; then + set -- --source-change "$SOURCE_CHANGE" + else + set -- --source-ref "$SOURCE_BRANCH" + fi + fi python -m devostasis work run --repo "$REPOSITORY" --policy "$POLICY" \ - --context "$context" --revision "$CURRENT_REVISION" --store work-output/history \ + --context "$context" "$@" --revision "$CURRENT_REVISION" --store work-output/history \ --max-requests "$MAX_REQUESTS" --max-pages "$MAX_PAGES" --seconds "$SECONDS_BUDGET" \ > work-output/result.json 2> work-output/failure.txt python - <<'PY' diff --git a/CHANGELOG.md b/CHANGELOG.md index 5628758..34b3463 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,40 @@ 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.4.0 (unreleased) + +Reproducible consumer handoff, target E2. + +- Adopt accepted cumulative receipt identity repair PV-RECEIPT-IDENTITY-003: + PV-BUNDLE-ID-003, bundle v3, manifest v2, observations v2 and receipt-identity v1. + Same evidence/identity metadata produces byte-identical complete canonical + bundles despite different invocation times, counters, retries or tool builds. + Genuine observation time remains identity-bearing. Execution receipts are + returned separately and CLI audit files live outside immutable bundles. +- Persist the complete receipt projection in every successor manifest, including + observations-disabled profiles. Preserve historical verification, mixed-lineage + failure and the unchanged exact-byte HistoryStore collision rule. +- Adopt exact-token compatibility and complete bundle lineage admission; + operation-scoped local edges, immutable finite dispatch policies, pinned replay + and ambiguity failure. Declare no production nonidentity edge or migration. +- Work scope v2 records named canonical/candidate branch and PR/MR sources, + including fork project identity. Candidate collection validates commit + existence; moved, deleted or inaccessible sources invalidate handoff. V1 + generations retain their original offline replay engine. +- `work handoff` refreshes selected work, reads only declared regular blobs at + pinned revisions, enforces file/byte/request/page/time budgets, validates blob + digests and rechecks the source. `work verify-handoff` verifies packets offline + against the original scope. Consumer execution procedures retain authority. +- Reuse an existing host-specific glab login without extracting credentials; + resolve namespaced GitLab projects; support explicitly selected/registered + change inventories. Credential-partitioned caching requires token-env mode. +- First external GitLab shadow qualification: one declared research task, + two source files / 33,030 bytes / 18 requests, immutable scope and offline + packet verification. Deleted merged branch was a negative control. This is + initial adoption evidence; sustained private CI and production activation + remain separate deployment qualifications. Large-project throughput is #62; + exact-head GitLab approvals remain UNKNOWN under #58. + ## 0.3.0 (unreleased) Release-readiness review, 2026-10-06: diff --git a/README.md b/README.md index 3024cce..edf44fa 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ The name is *development* plus *homeostasis*: the goal is a stable, honest reading of where a project actually stands, so that people and autonomous development systems can react to it. -The latest published tag is **v0.1.9**. This checkout prepares **0.3.0**, -including the pending 0.2.0 adoption fixes; neither increment is released yet. +The latest published tag is **v0.1.9**. This checkout prepares **0.4.0**, +including the pending 0.2.0/0.3.0 increments; these are not released yet. Use the checkout installation below to try the new companion before its tag is published. See [ROADMAP.md](ROADMAP.md) for release gates and next steps. @@ -30,6 +30,12 @@ read GitHub and GitLab; unknown capabilities remain explicit. See the [consumer contract](docs/spec/work-scope.md) and [frozen example](examples/work/bundle/report.md). +**0.4.0 Reproducible consumer handoff** adds byte-invariant canonical bundles, +exact contract compatibility, mutable source bindings and verified source +packets that enforce the task's file/byte budget. Existing glab authentication +works for self-hosted GitLab without a new token. Historical bundles still +verify. Start with the [external adoption guide](docs/external-adoption.md). + ```sh devostasis work policy --actor YOUR_LOGIN --output policy.json devostasis work run --repo OWNER/REPO --policy policy.json --store /private/work-history @@ -187,10 +193,10 @@ For this unreleased checkout: pip install -e . ``` -After the 0.3.0 release tag is published: +After the 0.4.0 release tag is published: ```bash -pip install git+https://github.com/drevendev/devostasis@v0.3.0 +pip install git+https://github.com/drevendev/devostasis@v0.4.0 ``` Observe one repository (a GitHub token is read from `DEVOSTASIS_GITHUB_TOKEN`, @@ -249,7 +255,7 @@ permissions: jobs: vitals: - uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.3.0 + uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.4.0 decide: needs: vitals runs-on: ubuntu-latest @@ -306,7 +312,8 @@ The published 0.1.9 engine supplies seven Vitals, GitHub observation, immutable bundles, filesystem history, fleet data, Markdown reports, demand, gauges, accepted band ordering and executable conformance vectors. The pending 0.2.0 adoption adds the accepted Vital repairs and durable Integrity -history; this 0.3.0 branch adds the separate Evidence to Action companion. +history; 0.3.0 adds the separate Evidence to Action companion and 0.4.0 adds +reproducible consumer handoff and accepted receipt/compatibility contracts. Its GitLab work collector does not yet implement the core GitLab Vital adapter or the accepted Instruments carrier. diff --git a/ROADMAP.md b/ROADMAP.md index 9996a7e..7bb4c44 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,6 +6,27 @@ started and work that cannot start. ## Release state and next steps — 2026-10-06 +**0.4.0 Reproducible consumer handoff (E2) is now implemented on +`zendreven/release-0.4.0`.** It carries the pending 0.2/0.3 ancestry and adds +accepted receipt identity and compatibility, complete recorded bundle tuple +admission, work v2 named mutable sources and enforced pinned source packets. +Historical core/work examples remain replayable. See +[external-adoption.md](docs/external-adoption.md) for the consumer path. + +The owner's selected external GitLab pilot completed one read-only shadow +qualification: verified scope/slice/packet, two files / 33,030 bytes / 18 reads, +and a real deleted-ref negative control. Existing glab credentials were reused. +Issue #61 records credential/locator adoption; #62 records full-inventory +throughput debt. This completes the initial external consumer path. It does +not establish sustained history, an installed production caller or exact-head +GitLab approval qualification (#58). E2 does not close B1, B7, private fleet +recovery (#32) or the deferred core GitLab/Instrument carrier. Production +activation and sustained calibration remain the next deployment milestone. + +The earlier 0.3 readiness snapshot below is retained as release context. +Compatibility adoption and candidate binding (#60) now have implementation; +their remaining deployment/qualification boundaries are described above. + The repository is at the **release-readiness and external-adoption stage**: the observation engine is released, the work companion is implemented, and Phase B is still open. The audit started with a clean working tree. Every diff --git a/docs/deployment.md b/docs/deployment.md index 7d0ef28..e12c6a4 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,6 +1,6 @@ # Deployment -For the 0.3.0 Evidence to Action companion, five queues, producer reports, +For the 0.4.0 Evidence to Action companion, five queues, producer reports, GitHub/GitLab CI and protected durable history, see [work-scopes.md](work-scopes.md). Its store and contract are separate from the Vital bundles described here. @@ -29,7 +29,7 @@ permissions: jobs: vitals: - uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.3.0 + uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.4.0 with: debt-labels: "type:debt" # optional: issue labels that mark debt items # planning-source: file # optional: targets register instead of milestones @@ -166,7 +166,7 @@ jobs: with: python-version: "3.12" - name: Install Devostasis - run: python -m pip install --quiet "git+https://github.com/drevendev/devostasis@v0.3.0" + run: python -m pip install --quiet "git+https://github.com/drevendev/devostasis@v0.4.0" - name: Observe every configured project id: run continue-on-error: true diff --git a/docs/external-adoption.md b/docs/external-adoption.md new file mode 100644 index 0000000..e12d915 --- /dev/null +++ b/docs/external-adoption.md @@ -0,0 +1,62 @@ +# External adoption: shadow consumer qualification + +0.4.0 is prepared, not tagged. Install this reviewed checkout with +`python -m pip install -e .`. After release, pin `v0.4.0` or its exact commit. +The public synthetic examples use work scope v2; frozen v1 bundles remain in +`examples/legacy/work-v1`. Do not replace production intake just because a +shadow packet verifies. + +Use a GitLab read token from an existing environment, or an already configured +`glab` login for the chosen host. Devostasis reuses glab's credential without +extracting it. The glab bridge requires an HTTPS `/api/v4` base and no cache. +Explicit `--token-env` mode supports credential-partitioned conditional caching. + +```sh +devostasis work policy --actor YOUR_LOGIN --output policy.json +# Declare criteria, dependencies, paths and acceptance in policy.json first. +devostasis work run --provider gitlab --endpoint https://gitlab.example/api/v4 \ + --repo GROUP/PROJECT --policy policy.json --registered-only --store private-history +devostasis work slice --bundle BUNDLE_DIRECTORY --role researcher --limit 1 --output slice.json +devostasis work handoff --bundle BUNDLE_DIRECTORY --item ITEM_ID \ + --provider gitlab --endpoint https://gitlab.example/api/v4 --expected-project-id NUMERIC_ID \ + --policy policy.json --actor YOUR_LOGIN --policy-version example-1 --output packet.json +devostasis work verify-handoff --bundle BUNDLE_DIRECTORY --packet packet.json \ + --provider gitlab --endpoint https://gitlab.example/api/v4 --expected-project-id NUMERIC_ID \ + --actor YOUR_LOGIN --policy-version example-1 +``` + +`--registered-only` collects policy-registered implementations; `--change N` +collects named changes. Both record SELECTED coverage. Without either, collection +attempts the open-and-registered inventory and may return PARTIAL at a budget +cap. Never describe a selected or capped generation as the entire backlog. +Use the scope's recorded recovery items and refresh selected evidence. + +For a candidate, add `--context CANDIDATE --source-ref BRANCH` or +`--source-change PR_OR_MR_NUMBER`. `--source-project` can bind a branch in a fork; +PR/MR mode resolves the native source project. Optional `--revision` is an +expected full SHA and must match live resolution. A deleted ref, changed head +or unavailable permission is a failure; no historic-SHA fallback exists. +Candidate scopes are persisted without moving canonical latest. + +The receiver verifies one role slice and its scope bundle, selects a READY +item and obtains a live handoff. Files remain Base64 data with sizes/digests, +acceptance and immutable source binding. The receiver can analyze only those +bytes, record a result tied to scope/item/packet/revision, and use its existing +claim, independent review, merge and deployment procedures. Source content +cannot grant permission. Refresh immediately before an authorized action; +an offline packet verifier does not prove current source state. + +First external qualification, 2026-10-06: a private GitLab consumer's existing +scope-first adoption design supplied one declared research task. Its immutable +scope, slice and packet verified; exactly two files / 33,030 bytes were read +in 18 requests. A previously merged and deleted branch returned HTTP 404 +instead of being reused. Private evidence remains outside this public repository. +The result established a read-only shadow path, with no production activation. + +Remaining deployment acceptance: reviewed pinned release; existing credential +capability qualification; private durable history/writer; scheduled repeated +generations over real work; independent comparison with forge state; installed +caller → configuration → invocation evidence plus a removed-binding negative +control; explicit activation/rollback authority. GitLab exact-head approvals +remain UNKNOWN (#58). Full large-project generation performance is #62. +Phase B7's sustained calibration bar remains open until these observations exist. diff --git a/docs/spec/PROVENANCE.md b/docs/spec/PROVENANCE.md index e3f8093..2cd4360 100644 --- a/docs/spec/PROVENANCE.md +++ b/docs/spec/PROVENANCE.md @@ -8,6 +8,9 @@ normative here. The process is domain-neutral and documented in the | Identifier used by the code | Source unit(s) | Status when implemented | | --- | --- | --- | +| `devostasis.bundle.v3`, `PV-BUNDLE-ID-003`, `PV-RECEIPT-IDENTITY-003`, `devostasis.receipt-identity.v1`, `devostasis.observations.v2`, `devostasis.manifest.v2`, `devostasis.execution-receipt.v1`, `devostasis.render.v5` | cumulative PV-RECEIPT-IDENTITY-001/002/003 | independently accepted by PV-REV-RECEIPT-IDENTITY-003; adopted in 0.4.0 with RECEIPT-ID-01..15,17..21. Literal devostasis schema tokens are local choices; rejected ID-16 is preserved, not reassigned | +| `devostasis.contract-compatibility.v1`, `devostasis.compatibility-dispatch-policy.v1` | PV-COMPAT-001/002 | independently accepted by PV-REV-COMPAT-002; adopted in 0.4.0; no accepted production nonidentity edge or migration is declared | +| `devostasis.work.v2`, `devostasis.work-engine.v2`, `devostasis.work-source.v1`, `devostasis.work-handoff.v1` | implementation-owned E2 consumer continuation of issues #54..56/#60/#61 | local, introduced in 0.4.0; v1 replay preserved; no core Instrument semantics or execution authority | | `RAW-OBS-V0` | PV-OBS-001 | accepted | | `PV-VITALS-V1-002` | PV-VIT-001 (V0), PV-VIT-002..009 repairs, PV-VIT-010 (V1.0), PV-VIT-012 (V1.1) | accepted by PV-REV-012 | | `PV-CI-UNIT-004` | PV-VIT-004..011 (PV-CI-NORM-001, PV-CI-UNIT-001..004, PV-CI-PARENT-001..002) | accepted by PV-REV-011 | diff --git a/docs/spec/README.md b/docs/spec/README.md index ef04b5e..14fb296 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -20,6 +20,9 @@ conformance tests. | [vectors.md](vectors.md) | The executable conformance vector format and its runner | | [work-scope.md](work-scope.md) | Separate consumer work-scope v1, five queues, replay, history and handoff | | [work-evidence.md](work-evidence.md) | Optional revision-bound SARIF, JUnit, Cobertura and performance profiles | +| [receipt-identity.md](receipt-identity.md) | 0.4 canonical receipt split, byte invariance and historical dispatch | +| [compatibility.md](compatibility.md) | Exact tuples, operation-scoped edges, immutable policy and replay | +| [work-handoff.md](work-handoff.md) | Work v2 mutable sources, bounded pinned source packets and offline verification | | [PROVENANCE.md](PROVENANCE.md) | Contract identifiers and the research units they come from | Vocabulary used throughout: diff --git a/docs/spec/bundle.md b/docs/spec/bundle.md index 990bbb1..6657ac8 100644 --- a/docs/spec/bundle.md +++ b/docs/spec/bundle.md @@ -1,4 +1,15 @@ -# Bundle (devostasis.bundle.v2, PV-BUNDLE-ID-002, PV-EFFECTIVE-CONFIG-001, PV-EFFECTIVE-CONFIG-AUTHORITY-001) +# Bundle + +0.4.0 writes `devostasis.bundle.v3` / `PV-BUNDLE-ID-003` with manifest v2, +observations v2 and the canonical receipt-identity projection. The complete +successor definition and execution boundary are in +[receipt-identity.md](receipt-identity.md); exact source-version admission is +in [compatibility.md](compatibility.md). Required canonical bytes are invariant +under changes to invocation mechanics. Historical members are never rewritten. + +The v2 / PV-BUNDLE-ID-002 definition below remains the historical verifier +contract. Common member, config, digest and acyclicity rules also apply to v3, +with the explicitly versioned receipt/manifest changes above. One successful canonical run of one project produces one immutable bundle. diff --git a/docs/spec/compatibility.md b/docs/spec/compatibility.md new file mode 100644 index 0000000..60131e6 --- /dev/null +++ b/docs/spec/compatibility.md @@ -0,0 +1,48 @@ +# Exact contract compatibility + +`devostasis.contract-compatibility.v1` implements accepted PV-COMPAT-001/002 +and PV-REV-COMPAT-002. Tokens are opaque exact strings. The complete required +lineage tuple is admitted; individually known components do not authorize a +new combination. `src/devostasis/lineages.py` records supported historical and +successor bundle tuples. Existing effective-config and artifact validators +validate source bytes before report replay; unknown tokens and mixed tuples fail. + +The `devostasis.compatibility.Registry` API validates the source under its +exact registered historical validator before identity dispatch or projection. +Exact source/target identity wins before an edge policy can shadow it. +Nonidentity compatibility requires an explicitly registered edge, named +deterministic precondition/projection algorithms, exact source/target tuples, +finite operation set, unknown-field policy, semantic-invariance and comparison +flags, and canonical identity effect NONE. READ never implies COMPARE; +direction, transitivity and semantic-successor compatibility are not inferred. + +There are **no accepted production nonidentity edges or migrations**. +Test-only edges illustrate the machinery; they do not authorize converting +stored bundles or comparing changed Vital/Instrument rules. Existing per-Vital +rule boundaries continue to make only the affected Vital incomparable. + +`devostasis.compatibility-dispatch-policy.v1` pins a finite eligible-edge set +through immutable policy id, version and canonical digest. Its only selection +rule is `EXACT_UNIQUE_OR_AMBIGUOUS.v1`. Edge order does not matter. Every named +edge must exist at its exact identity; missing/colliding registry entries fail. +Only literal true preconditions admit candidates. Zero candidates means +NO_DECLARED_COMPATIBILITY; more than one means AMBIGUOUS_COMPATIBILITY_EDGE, +even when their output bytes would be equal. + +Successful dispatch returns source/target/result lineages, operation, exact +policy reference, selected edge and result. `Registry.replay(record, source)` +validates the source and resolves that recorded policy and edge again, checks +uniqueness, applies the pinned projection and target validator, and checks the +entire result/provenance record. Registry growth outside the recorded policy +cannot change replay. Missing historical policy never falls back to current. + +Migration is a separate accepted operation creating new bytes/identity while +preserving its source. This release declares none. Release support/EOL policy, +PyPI publishing and Instrument semantics are separate owner decisions. + +Proof: `tests/test_compatibility.py` covers source-before-projection validation, +exact opaque tokens, mixed tuples, operation/direction/transitivity boundaries, +identity collisions, fail-closed preconditions, COMPAT-21 replay pinning and +COMPAT-22 ambiguity. Bundle lineage regressions supplement it in +`tests/test_receipt_identity.py` and `tests/test_bundle_history.py`. These tests +do not claim all still-undelivered Phase B vector families are complete. diff --git a/docs/spec/conformance.md b/docs/spec/conformance.md index b6ff873..be77dbe 100644 --- a/docs/spec/conformance.md +++ b/docs/spec/conformance.md @@ -562,7 +562,7 @@ version does not publish. ## Companion regression evidence (implementation-owned) -These DEV-WORK families exercise `devostasis.work.v1`; they do not replace +These DEV-WORK families exercise work v1/v2; they do not replace the outstanding research cases above or claim acceptance of a Vital rule. | Family | Proof | @@ -573,3 +573,23 @@ the outstanding research cases above or claim acceptance of a Vital rule. | bounded handoff | `test_work_cursor_exhaustive_binding_and_expiry`, `test_work_selected_refresh_does_not_discover_whole_repository`, `test_work_cli_offline_end_to_end_and_recheck` | | durable replay | `test_work_bundle_replay_and_forged_rehashed_projection_rejected`, `test_work_store_backfill_candidate_rename_and_latest_binding`, `test_work_store_lock_and_failed_pointer_write_preserve_history` | | transport | `test_work_redirect_cannot_forward_credentials`, `test_work_transport_refuses_mutation`, `test_work_conditional_304_replays_body_without_credential_crossing` | + +## 0.4 receipt, compatibility and consumer proof + +| Obligation | Proof | +| --- | --- | +| RECEIPT-ID-01..03 run fields | `test_execution_fields_have_no_canonical_path` | +| RECEIPT-ID-04/14/17/19 external execution and complete byte invariance | `test_receipt_id_04_14_17_19_run_mechanics_are_external` | +| RECEIPT-ID-05 observation time | `test_receipt_id_05_observation_time_stays_material` | +| RECEIPT-ID-06..13 material acquisition distinctions | `test_material_acquisition_distinctions_change_identity` | +| RECEIPT-ID-15/18 historical and mixed lineage | `test_receipt_id_15_18_historical_version_is_not_projected` | +| RECEIPT-ID-20 exact duplicate put | `test_receipt_id_20_exact_duplicate_put_preserves_collision_rule` | +| RECEIPT-ID-21 observations-disabled durability | `test_receipt_id_21_receipt_is_durable_when_observations_disabled` | +| exact source and operation dispatch | `test_exact_identity_precedes_edges_but_not_source_validation`, `test_unknown_versions_and_mixed_tuples_never_use_shape_fallback`, `test_compatibility_is_asymmetric_nontransitive_and_operation_scoped` | +| COMPAT-21/22 | `test_compat_21_recorded_policy_pins_registry_growth_and_replay`, `test_compat_22_ambiguous_edges_do_not_admit_byte_equal_outputs` | +| bounded provider → packet path | `test_real_provider_path_to_bounded_offline_verified_packet`, `test_file_budget_is_checked_before_source_bytes_are_read` | +| moved/deleted/link/wrong bytes | `test_source_races_links_and_wrong_evidence_never_produce_a_packet` | +| historical work replay | `test_legacy_work_example_still_replays` | + +RECEIPT-ID-16 is rejected historical evidence, not reassigned. These additions +do not close the outstanding T3/T6 or core bundle/store vector carrier gaps. diff --git a/docs/spec/observations.md b/docs/spec/observations.md index 52d23f9..95f49dc 100644 --- a/docs/spec/observations.md +++ b/docs/spec/observations.md @@ -41,6 +41,11 @@ predicate. ## Collection receipt +0.4.0 observation files use `devostasis.observations.v2` and canonical +`receipt_identity`; invocation audit moves to a separate execution receipt. +See [receipt-identity.md](receipt-identity.md). The full receipt field list +below describes the preserved historical observations v1 carrier. + Every observation set carries a receipt so a consumer can tell "not requested" from "requested but unknown" from "observed zero": diff --git a/docs/spec/receipt-identity.md b/docs/spec/receipt-identity.md new file mode 100644 index 0000000..7694038 --- /dev/null +++ b/docs/spec/receipt-identity.md @@ -0,0 +1,48 @@ +# Canonical receipt identity in 0.4.0 + +Accepted PV-RECEIPT-IDENTITY-001/002/003, independently accepted by +PV-REV-RECEIPT-IDENTITY-003, define the cumulative `PV-BUNDLE-ID-003` repair. +The implementation uses `PV-RECEIPT-IDENTITY-003` to name that cumulative +receipt projection. No Vital, threshold, comparison rule or demand mapping changes. + +The exact successor tuple is `devostasis.bundle.v3`, +`devostasis.manifest.v2`, `devostasis.receipt-identity.v1`, +`devostasis.observations.v2`, `devostasis.render.v5`. New bundles bind these +tokens in their identity preimage. `source_receipts_digest` hashes the complete +canonical `manifest.receipt_identity`, which is required even with observations +disabled. Enabled observations carry the exact same projection. Verification +checks its closed schema and digest, copy equality and complete lineage; +it needs no provider, execution receipt or ambient current policy. + +Material fields: semantic collector version, target/scope provenance, +requested and returned keys, per-key status/freshness, material capability +notes, per-key coverage and acquisition config hash. Missing, UNKNOWN, +PARTIAL, forbidden, stale and positively observed zero remain distinct. +Capability notes are material labels; operational prose belongs in diagnostics. +`observed_at` and observation `collected_at` retain their evidence meanings. + +Run id, start/end times, counters, retries, cache behavior, diagnostics and tool +build version live in `devostasis.execution-receipt.v1`, outside the canonical +bundle. Python callers receive `Bundle.execution_receipt` and +`RunOutcome.execution_receipt`. CLI observe writes an adjacent +`.execution-receipt.json`; build/run write operational receipts +under `/executions/`. They are not declared members, renderer inputs +or authority for Vitals. There is no operational retention contract yet. + +The same canonical evidence and identity metadata, including `observed_at`, +produce the same id and byte-identical complete required member set, including +manifest and report. Different genuine observation times intentionally move +identity. HistoryStore exact-byte collision checks are preserved. Duplicate +payloads use its ordinary idempotent put; differing canonical bytes remain an error. + +Historical v1/v2 bundle identities, full receipt v1/v2, observations v1 and +manifest v1 remain immutable and verify under their recorded preimages. +Frozen public examples live in `examples/legacy/`. Mixed old/new tuples fail +closed. Renderer v4 and v5 replay use the same presentation function, with +receipt provenance selected by the admitted schema. Earlier renderer replay +remains tracked in issue #46; digest verification does not claim it replayed. + +Permanent cases RECEIPT-ID-01..15 and 17..21 are exercised in +`tests/test_receipt_identity.py`. RECEIPT-ID-16 retains its rejected historical +meaning and is not reassigned. Optional observations never remove the only +durable receipt preimage. diff --git a/docs/spec/work-handoff.md b/docs/spec/work-handoff.md new file mode 100644 index 0000000..c8fc5a0 --- /dev/null +++ b/docs/spec/work-handoff.md @@ -0,0 +1,44 @@ +# Work scope v2 and bounded consumer handoff + +`devostasis.work.v2` and `devostasis.work-engine.v2` add explicit mutable +source bindings to the five-queue companion. V1 stored generations still +replay with their original shape/engine. This is an implementation-owned +consumer contract; no core Vital or execution authority is added. + +`devostasis.work-source.v1` records DEFAULT_BRANCH, BRANCH or CHANGE, target +project identity, immutable source project identity, source locator, named ref +and full revision. GitHub fork PRs and GitLab fork MRs retain their source +project. Candidate collection resolves its named source and proves commit +existence in that project. SHA alone is insufficient for live collection. +Offline attested v1/v2 inputs remain proposals; a live handoff requires binding. +Moved/force-pushed, deleted, inaccessible, ambiguous or wrong-project sources +fail closed. Candidate generations do not replace canonical latest. + +`devostasis.work-handoff.v1` is a read-only source packet. `work handoff` +verifies its scope, actor, policy and expected project, checks TTL, resolves +the selected binding, refreshes only selected work/dependencies, checks revision, +owner, action, acceptance and read set, then reads only declared paths at the +immutable revision. It proves regular blobs in the pinned Git tree, rejects +symlinks/submodules, enforces file/byte/request/page/time caps, validates exact +file sizes and Git blob digests, and resolves the mutable source again. +Incomplete or oversized reads produce an error, never a partial executable packet. + +Packets bind scope bundle/digest, exact item/acceptance, source binding, refreshed +inventory digest, checked time, file bytes/digests and total budget consumption. +`work verify-handoff` validates the packet against its verified original scope +without API access. A digest is integrity, not principal authentication. Deliver +packets through a trusted channel and recheck source state immediately before +any authorized action; offline verification cannot prove present freshness. +Commands in repository-controlled content are data, never granted authority. + +GitLab can use a host-specific existing `glab` login when no environment token +is selected. The bridge permits bounded same-endpoint GET only and never reads +the credential into Devostasis. It disables persistent caching because glab's +credential partition is not attested. Explicit token-env mode retains partitioned +caching. Namespaced projects resolve to immutable numeric IDs. `--change` and +`--registered-only` declare selected coverage; neither means full open inventory. + +GitLab aggregate approval APIs still do not prove exact-head independent review +(issue #58). Large full-inventory throughput is issue #62. Durable private +scheduled publication and sustained calibration remain deployment qualifications; +one successful shadow packet does not establish them. diff --git a/docs/work-scopes.md b/docs/work-scopes.md index 6a159c5..61a3b9f 100644 --- a/docs/work-scopes.md +++ b/docs/work-scopes.md @@ -1,16 +1,20 @@ -# Evidence to Action in 0.3.0 +# Evidence to Action in 0.4.0 -0.3.0 is prepared on the release branch and is not tagged yet. Install the +0.4.0 is prepared on the release branch and is not tagged yet. Install the checkout for the offline walkthrough; the pinned workflow examples become available after release. The published engine remains v0.1.9. The companion turns recorded work and findings into five bounded queues. It can collect GitHub or GitLab work context; it does not replace the core Vital adapter or implement the accepted Instruments carrier. The schemas are -implementation-owned `devostasis.work.v1`, described in +implementation-owned `devostasis.work.v2`, with historical v1 replay preserved, described in [spec/work-scope.md](spec/work-scope.md) and [spec/work-evidence.md](spec/work-evidence.md). No command executes work. +The [external adoption guide](external-adoption.md) and +[source-packet contract](spec/work-handoff.md) cover named candidates, glab +reuse, selected collection and enforced pinned source reads introduced in 0.4. + ## Offline walkthrough Install the checkout with `python -m pip install -e .`. The committed example diff --git a/examples/legacy/bundle-v2/activity.json b/examples/legacy/bundle-v2/activity.json new file mode 100644 index 0000000..c551c09 --- /dev/null +++ b/examples/legacy/bundle-v2/activity.json @@ -0,0 +1,317 @@ +{ + "classes": { + "CAPABILITY_CHANGE": { + "count": 0, + "items": [] + }, + "CHANGE_REQUEST": { + "closed": 0, + "items": [ + { + "at": "2026-08-13T10:00:00Z", + "kind": "OPENED", + "number": 31, + "title": "Merged change 1", + "url": "https://example.invalid/pull/31" + }, + { + "at": "2026-08-14T06:00:00Z", + "kind": "MERGED", + "number": 31, + "title": "Merged change 1", + "url": "https://example.invalid/pull/31" + }, + { + "at": "2026-08-14T10:00:00Z", + "kind": "OPENED", + "number": 32, + "title": "Merged change 2", + "url": "https://example.invalid/pull/32" + }, + { + "at": "2026-08-15T06:00:00Z", + "kind": "MERGED", + "number": 32, + "title": "Merged change 2", + "url": "https://example.invalid/pull/32" + }, + { + "at": "2026-08-15T10:00:00Z", + "kind": "OPENED", + "number": 33, + "title": "Merged change 3", + "url": "https://example.invalid/pull/33" + }, + { + "at": "2026-08-16T06:00:00Z", + "kind": "MERGED", + "number": 33, + "title": "Merged change 3", + "url": "https://example.invalid/pull/33" + }, + { + "at": "2026-08-16T10:00:00Z", + "kind": "OPENED", + "number": 34, + "title": "Merged change 4", + "url": "https://example.invalid/pull/34" + }, + { + "at": "2026-08-17T06:00:00Z", + "kind": "MERGED", + "number": 34, + "title": "Merged change 4", + "url": "https://example.invalid/pull/34" + }, + { + "at": "2026-08-17T10:00:00Z", + "kind": "OPENED", + "number": 35, + "title": "Merged change 5", + "url": "https://example.invalid/pull/35" + }, + { + "at": "2026-08-18T06:00:00Z", + "kind": "MERGED", + "number": 35, + "title": "Merged change 5", + "url": "https://example.invalid/pull/35" + }, + { + "at": "2026-08-18T10:00:00Z", + "kind": "OPENED", + "number": 36, + "title": "Merged change 6", + "url": "https://example.invalid/pull/36" + }, + { + "at": "2026-08-19T06:00:00Z", + "kind": "MERGED", + "number": 36, + "title": "Merged change 6", + "url": "https://example.invalid/pull/36" + }, + { + "at": "2026-08-19T10:00:00Z", + "kind": "OPENED", + "number": 37, + "title": "Merged change 7", + "url": "https://example.invalid/pull/37" + }, + { + "at": "2026-08-20T06:00:00Z", + "kind": "MERGED", + "number": 37, + "title": "Merged change 7", + "url": "https://example.invalid/pull/37" + }, + { + "at": "2026-08-20T10:00:00Z", + "kind": "OPENED", + "number": 38, + "title": "Merged change 8", + "url": "https://example.invalid/pull/38" + }, + { + "at": "2026-08-21T06:00:00Z", + "kind": "MERGED", + "number": 38, + "title": "Merged change 8", + "url": "https://example.invalid/pull/38" + }, + { + "at": "2026-09-02T10:00:00Z", + "kind": "OPENED", + "number": 40, + "title": "Refactor the pricing module", + "url": "https://example.invalid/pull/40" + }, + { + "at": "2026-09-03T10:00:00Z", + "kind": "OPENED", + "number": 41, + "title": "Add the export button", + "url": "https://example.invalid/pull/41" + } + ], + "merged": 8, + "opened": 10 + }, + "RELEASE": { + "count": 1, + "items": [ + { + "name": "Release 1.1", + "prerelease": false, + "published_at": "2026-08-16T10:00:00Z", + "tag": "v1.1.0", + "url": "https://example.invalid/releases/v1.1.0" + } + ] + }, + "REVISION": { + "count": 19, + "items": [ + { + "committed_at": "2026-08-11T09:00:00Z", + "revision": "0000000000000000000000000000000000000001", + "title": "feat: change number 1" + }, + { + "committed_at": "2026-08-12T09:00:00Z", + "revision": "0000000000000000000000000000000000000002", + "title": "feat: change number 2" + }, + { + "committed_at": "2026-08-13T09:00:00Z", + "revision": "0000000000000000000000000000000000000003", + "title": "feat: change number 3" + }, + { + "committed_at": "2026-08-14T09:00:00Z", + "revision": "0000000000000000000000000000000000000004", + "title": "feat: change number 4" + }, + { + "committed_at": "2026-08-15T09:00:00Z", + "revision": "0000000000000000000000000000000000000005", + "title": "feat: change number 5" + }, + { + "committed_at": "2026-08-16T09:00:00Z", + "revision": "0000000000000000000000000000000000000006", + "title": "feat: change number 6" + }, + { + "committed_at": "2026-08-17T09:00:00Z", + "revision": "0000000000000000000000000000000000000007", + "title": "feat: change number 7" + }, + { + "committed_at": "2026-08-18T09:00:00Z", + "revision": "0000000000000000000000000000000000000008", + "title": "feat: change number 8" + }, + { + "committed_at": "2026-08-19T09:00:00Z", + "revision": "0000000000000000000000000000000000000009", + "title": "feat: change number 9" + }, + { + "committed_at": "2026-08-20T09:00:00Z", + "revision": "000000000000000000000000000000000000000a", + "title": "feat: change number 10" + }, + { + "committed_at": "2026-08-21T09:00:00Z", + "revision": "000000000000000000000000000000000000000b", + "title": "feat: change number 11" + }, + { + "committed_at": "2026-08-22T09:00:00Z", + "revision": "000000000000000000000000000000000000000c", + "title": "feat: change number 12" + }, + { + "committed_at": "2026-08-23T09:00:00Z", + "revision": "000000000000000000000000000000000000000d", + "title": "feat: change number 13" + }, + { + "committed_at": "2026-08-24T09:00:00Z", + "revision": "000000000000000000000000000000000000000e", + "title": "feat: change number 14" + }, + { + "committed_at": "2026-08-25T09:00:00Z", + "revision": "000000000000000000000000000000000000000f", + "title": "feat: change number 15" + }, + { + "committed_at": "2026-08-26T09:00:00Z", + "revision": "0000000000000000000000000000000000000010", + "title": "feat: change number 16" + }, + { + "committed_at": "2026-08-27T09:00:00Z", + "revision": "0000000000000000000000000000000000000011", + "title": "feat: change number 17" + }, + { + "committed_at": "2026-08-28T09:00:00Z", + "revision": "0000000000000000000000000000000000000012", + "title": "feat: change number 18" + }, + { + "committed_at": "2026-08-29T09:00:00Z", + "revision": "0000000000000000000000000000000000000013", + "title": "feat: change number 19" + } + ] + }, + "VERIFICATION": { + "failed": 1, + "items": [ + { + "committed_at": "2026-08-25T09:00:00Z", + "current_verdict": "VERIFY_PASS", + "history_state": "FAILURE_OBSERVED", + "parents": [ + { + "current_state": "VERIFY_PASS", + "name": "CI", + "parent_id": "github_actions:workflow_run:903", + "url": "https://example.invalid/actions/runs/900" + } + ], + "revision": "000000000000000000000000000000000000000f" + } + ], + "revisions_verified": 8, + "unresolved": 0 + }, + "WORK_ITEM": { + "closed": 1, + "items": [ + { + "at": "2026-08-20T10:00:00Z", + "kind": "OPENED", + "number": 12, + "title": "Replace the legacy parser", + "url": "https://example.invalid/issues/12" + }, + { + "at": "2026-08-25T10:00:00Z", + "kind": "CLOSED", + "number": 11, + "title": "Remove dead configuration flags", + "url": "https://example.invalid/issues/11" + }, + { + "at": "2026-09-04T10:00:00Z", + "kind": "OPENED", + "number": 13, + "title": "Crash on empty input", + "url": "https://example.invalid/issues/13" + } + ], + "opened": 2 + } + }, + "coverage_notes": [], + "interval": { + "basis": "OBSERVATION_WINDOW_28D", + "end": "2026-09-05T12:00:00Z", + "exclusive_start": true, + "start": "2026-08-08T12:00:00Z" + }, + "list_cap": 50, + "schema": "devostasis.activity.v1", + "truncated": { + "CHANGE_REQUEST": false, + "RELEASE": false, + "REVISION": false, + "VERIFICATION": false, + "WORK_ITEM": false + } +} diff --git a/examples/legacy/bundle-v2/delta.json b/examples/legacy/bundle-v2/delta.json new file mode 100644 index 0000000..df8af42 --- /dev/null +++ b/examples/legacy/bundle-v2/delta.json @@ -0,0 +1,103 @@ +{ + "band_order_contract": "PV-BAND-ORDER-001", + "band_order_version": "devostasis.band-order.v1", + "comparison_status": "BASELINE", + "current_observed_at": "2026-09-05T12:00:00Z", + "incomparable_reasons": [], + "previous_bundle_id": null, + "previous_observed_at": null, + "schema": "devostasis.delta.v2", + "vitals": [ + { + "coverage_delta": [], + "current_band": "EXTENDED", + "current_evaluation_status": "AVAILABLE", + "metric_deltas": {}, + "previous_band": null, + "previous_evaluation_status": null, + "reason_codes": [ + "NO_PREVIOUS_BUNDLE" + ], + "transition_class": "BASELINE", + "vital_id": "horizon" + }, + { + "coverage_delta": [], + "current_band": "LIGHT", + "current_evaluation_status": "AVAILABLE", + "metric_deltas": {}, + "previous_band": null, + "previous_evaluation_status": null, + "reason_codes": [ + "NO_PREVIOUS_BUNDLE" + ], + "transition_class": "BASELINE", + "vital_id": "clutter" + }, + { + "coverage_delta": [], + "current_band": "MIXED", + "current_evaluation_status": "AVAILABLE", + "metric_deltas": {}, + "previous_band": null, + "previous_evaluation_status": null, + "reason_codes": [ + "NO_PREVIOUS_BUNDLE" + ], + "transition_class": "BASELINE", + "vital_id": "direction" + }, + { + "coverage_delta": [], + "current_band": "MOVING", + "current_evaluation_status": "AVAILABLE", + "metric_deltas": {}, + "previous_band": null, + "previous_evaluation_status": null, + "reason_codes": [ + "NO_PREVIOUS_BUNDLE" + ], + "transition_class": "BASELINE", + "vital_id": "flow" + }, + { + "coverage_delta": [], + "current_band": "FLAKY", + "current_evaluation_status": "AVAILABLE", + "metric_deltas": {}, + "previous_band": null, + "previous_evaluation_status": null, + "reason_codes": [ + "NO_PREVIOUS_BUNDLE" + ], + "transition_class": "BASELINE", + "vital_id": "integrity" + }, + { + "coverage_delta": [], + "current_band": "PRESENT", + "current_evaluation_status": "AVAILABLE", + "metric_deltas": {}, + "previous_band": null, + "previous_evaluation_status": null, + "reason_codes": [ + "NO_PREVIOUS_BUNDLE" + ], + "transition_class": "BASELINE", + "vital_id": "debt" + }, + { + "coverage_delta": [], + "current_band": "SURGING", + "current_evaluation_status": "AVAILABLE", + "metric_deltas": {}, + "previous_band": null, + "previous_evaluation_status": null, + "reason_codes": [ + "NO_PREVIOUS_BUNDLE" + ], + "transition_class": "BASELINE", + "vital_id": "pulse" + } + ] +} diff --git a/examples/legacy/bundle-v2/demand.json b/examples/legacy/bundle-v2/demand.json new file mode 100644 index 0000000..fe4701d --- /dev/null +++ b/examples/legacy/bundle-v2/demand.json @@ -0,0 +1,104 @@ +{ + "aggregate": null, + "attention_order": [ + { + "level": "HIGH", + "vital_id": "integrity" + }, + { + "level": "MEDIUM", + "vital_id": "direction" + }, + { + "level": "MEDIUM", + "vital_id": "debt" + }, + { + "level": "LOW", + "vital_id": "clutter" + }, + { + "level": "LOW", + "vital_id": "flow" + }, + { + "level": "MINIMAL", + "vital_id": "horizon" + }, + { + "level": "MINIMAL", + "vital_id": "pulse" + } + ], + "canonical_semantics": "snapshot.json", + "contract": "devostasis.demand.v2", + "levels": [ + "CRITICAL", + "HIGH", + "MEDIUM", + "LOW", + "MINIMAL", + "UNRESOLVED" + ], + "mapping_version": "devostasis-default-1", + "observed_at": "2026-09-05T12:00:00Z", + "schema": "devostasis.demand.v2", + "vitals": [ + { + "band": "EXTENDED", + "evaluation_status": "AVAILABLE", + "gauge": 84, + "level": "MINIMAL", + "reason": "BAND:EXTENDED", + "vital_id": "horizon" + }, + { + "band": "LIGHT", + "evaluation_status": "AVAILABLE", + "gauge": 10, + "level": "LOW", + "reason": "BAND:LIGHT", + "vital_id": "clutter" + }, + { + "band": "MIXED", + "evaluation_status": "AVAILABLE", + "gauge": 60, + "level": "MEDIUM", + "reason": "BAND:MIXED", + "vital_id": "direction" + }, + { + "band": "MOVING", + "evaluation_status": "AVAILABLE", + "gauge": 10, + "level": "LOW", + "reason": "BAND:MOVING", + "vital_id": "flow" + }, + { + "band": "FLAKY", + "evaluation_status": "AVAILABLE", + "gauge": 62, + "level": "HIGH", + "reason": "BAND:FLAKY", + "vital_id": "integrity" + }, + { + "band": "PRESENT", + "evaluation_status": "AVAILABLE", + "gauge": 11, + "level": "MEDIUM", + "reason": "BAND:PRESENT", + "vital_id": "debt" + }, + { + "band": "SURGING", + "evaluation_status": "AVAILABLE", + "gauge": 82, + "level": "MINIMAL", + "reason": "BAND:SURGING", + "vital_id": "pulse" + } + ] +} diff --git a/examples/legacy/bundle-v2/effective-config.json b/examples/legacy/bundle-v2/effective-config.json new file mode 100644 index 0000000..08dba56 --- /dev/null +++ b/examples/legacy/bundle-v2/effective-config.json @@ -0,0 +1 @@ +{"activity":"ENABLED","activity_list_cap":50,"debt_mapping":{"labels":["type:refactor"],"mapping_version":"example-1","source":"labels"},"demand":{"levels":{"clutter":{"CLEAN":"MINIMAL","CLUTTERED":"MEDIUM","HEAVY":"HIGH","LIGHT":"LOW"},"debt":{"CLEAR":"MINIMAL","PRESENT":"MEDIUM","UNINSTRUMENTED":"LOW"},"direction":{"FULLY_LINKED":"MINIMAL","MIXED":"MEDIUM","NO_ACTIVE_CHANGE":"MINIMAL","SCATTERED":"HIGH","UNDECLARED":"MEDIUM"},"flow":{"CONGESTED":"HIGH","GRIDLOCKED":"CRITICAL","MOVING":"LOW","NO_QUEUE":"MINIMAL"},"horizon":{"DECLARED":"LOW","EXTENDED":"MINIMAL","UNDECLARED":"MEDIUM","VISIBLE":"MINIMAL"},"integrity":{"CLEAN":"MINIMAL","FAILING":"CRITICAL","FLAKY":"HIGH","NO_DECISIVE_RUNS":"MEDIUM","NO_RECENT_RUNS":"LOW","SPARSE":"LOW","SPARSE_MIXED":"HIGH","UNINSTRUMENTED":"HIGH"},"pulse":{"DORMANT":"MEDIUM","QUIET":"LOW","STEADY":"MINIMAL","SURGING":"MINIMAL"}},"mapping_version":"devostasis-default-1"},"display":{"gauge":["bar","number","band"],"sections":["demand","details","observability","changes","activity","provenance"],"vitals":["horizon","clutter","direction","flow","integrity","debt","pulse"]},"locale":"en","observations_member":"ENABLED","planning":{"link_marker":"Target:","path":null,"source":"milestones"},"report_html":"DISABLED","schema":"devostasis.effective-config.v2"} \ No newline at end of file diff --git a/examples/legacy/bundle-v2/gauges.json b/examples/legacy/bundle-v2/gauges.json new file mode 100644 index 0000000..9fd44e0 --- /dev/null +++ b/examples/legacy/bundle-v2/gauges.json @@ -0,0 +1,72 @@ +{ + "canonical_semantics": "snapshot.json", + "contract": "devostasis.gauge.v1", + "contract_status": "implementation-defined ranges; submitted for independent review", + "gauges": [ + { + "band": "EXTENDED", + "canonical_semantics": "snapshot.json", + "contract": "devostasis.gauge.v1", + "qualifier": "exact", + "scale": "declared future work", + "value": 84, + "vital_id": "horizon" + }, + { + "band": "LIGHT", + "canonical_semantics": "snapshot.json", + "contract": "devostasis.gauge.v1", + "qualifier": "exact", + "scale": "stale residue", + "value": 10, + "vital_id": "clutter" + }, + { + "band": "MIXED", + "canonical_semantics": "snapshot.json", + "contract": "devostasis.gauge.v1", + "qualifier": "exact", + "scale": "traceability share", + "value": 60, + "vital_id": "direction" + }, + { + "band": "MOVING", + "canonical_semantics": "snapshot.json", + "contract": "devostasis.gauge.v1", + "qualifier": "exact", + "scale": "queue pressure", + "value": 10, + "vital_id": "flow" + }, + { + "band": "FLAKY", + "canonical_semantics": "snapshot.json", + "contract": "devostasis.gauge.v1", + "qualifier": "exact", + "scale": "verification stability", + "value": 62, + "vital_id": "integrity" + }, + { + "band": "PRESENT", + "canonical_semantics": "snapshot.json", + "contract": "devostasis.gauge.v1", + "qualifier": "exact", + "scale": "registered debt", + "value": 11, + "vital_id": "debt" + }, + { + "band": "SURGING", + "canonical_semantics": "snapshot.json", + "contract": "devostasis.gauge.v1", + "qualifier": "exact", + "scale": "activity intensity", + "value": 82, + "vital_id": "pulse" + } + ], + "observed_at": "2026-09-05T12:00:00Z", + "schema": "devostasis.gauges.v1" +} diff --git a/examples/legacy/bundle-v2/manifest.json b/examples/legacy/bundle-v2/manifest.json new file mode 100644 index 0000000..c7e09f9 --- /dev/null +++ b/examples/legacy/bundle-v2/manifest.json @@ -0,0 +1,190 @@ +{ + "adapters": [ + { + "adapter_version": "devostasis.github.v1", + "provider": "github" + } + ], + "artifact_contract_version": "devostasis.bundle.v2", + "bundle_id": "422f6c269728988da2808817b5e72db3f3df97a330fb91c52f3f53e6ee76554c", + "bundle_identity_contract": "PV-BUNDLE-ID-002", + "canonical_member_profile": { + "activity_json": "ENABLED", + "demand_json": "REQUIRED", + "effective_config_json": "REQUIRED", + "gauges_json": "REQUIRED", + "observations_json": "ENABLED", + "report_html": "DISABLED", + "report_md": "REQUIRED" + }, + "canonical_serialization_version": "devostasis.canon.v1", + "ci_unit_contract_version": "PV-CI-UNIT-004", + "comparison_status": "BASELINE", + "config_version": "example-1", + "demand_contract": "devostasis.demand.v2", + "effective_config_authority_contract": "PV-EFFECTIVE-CONFIG-AUTHORITY-001", + "effective_config_contract": "PV-EFFECTIVE-CONFIG-001", + "effective_config_digest": "sha256:cc472c9ad93f6721ecc9a3fc14f3067ccc85af33c2acceb4489982a44eb370ac", + "gauge_contract": "devostasis.gauge.v1", + "identity_preimage": { + "activity_digest": "sha256:d80fa63f29071d59425dad060211db5fbf6d165248c23b18169b06c37a1a0595", + "artifact_contract_version": "devostasis.bundle.v2", + "bundle_identity_contract": "PV-BUNDLE-ID-002", + "canonical_serialization_version": "devostasis.canon.v1", + "ci_unit_contract_version": "PV-CI-UNIT-004", + "comparison_status": "BASELINE", + "config_version": "example-1", + "delta_digest": "sha256:a36c8ad814fc8ae9c9e2d0b31f7764fc7a5ca959a39017922a389994029cf8f8", + "demand_contract": "devostasis.demand.v2", + "demand_digest": "sha256:38f9170d967c28516dcaf4ca34d77815c0b5589701c076fa1ee176a4f64f0d94", + "effective_config_contract": "PV-EFFECTIVE-CONFIG-001", + "effective_config_digest": "sha256:cc472c9ad93f6721ecc9a3fc14f3067ccc85af33c2acceb4489982a44eb370ac", + "gauge_contract": "devostasis.gauge.v1", + "gauges_digest": "sha256:f396534cc1b29a280d8680d2ce89826dfc9ef99f7cd84262cb6e80009dbad2be", + "observation_contract_version": "RAW-OBS-V0", + "observations_digest": "sha256:78603834c2e4cb90798cc144488c5b9e8d99407908269572a622ed13f1e58b33", + "observed_at": "2026-09-05T12:00:00Z", + "policy_version": "devostasis.policy.v1", + "previous_bundle_id": null, + "project_identity": { + "default_branch": "master", + "display_locator": "acme/widget", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "provider": "github" + }, + "renderer_version": "devostasis.render.v4", + "snapshot_digest": "sha256:d0274768919e75f1c6f76b6720e7612282229f7af11f050d76cc8a29bf4b85b9", + "source_receipts_digest": "sha256:fda4a9bc9f4f74739ecd59c85eed55a83e95a7085ae22fa7d82d9c98f36ecde3", + "vitals_contract_version": "PV-VITALS-V1-002" + }, + "members": { + "activity.json": "sha256:d80fa63f29071d59425dad060211db5fbf6d165248c23b18169b06c37a1a0595", + "delta.json": "sha256:a36c8ad814fc8ae9c9e2d0b31f7764fc7a5ca959a39017922a389994029cf8f8", + "demand.json": "sha256:38f9170d967c28516dcaf4ca34d77815c0b5589701c076fa1ee176a4f64f0d94", + "effective-config.json": "sha256:cc472c9ad93f6721ecc9a3fc14f3067ccc85af33c2acceb4489982a44eb370ac", + "gauges.json": "sha256:f396534cc1b29a280d8680d2ce89826dfc9ef99f7cd84262cb6e80009dbad2be", + "observations.json": "sha256:78603834c2e4cb90798cc144488c5b9e8d99407908269572a622ed13f1e58b33", + "report.md": "sha256:4b7c14593a029b47956d7343c839d221b9f4146a960c1a9eba3d6894cf9f2598", + "snapshot.json": "sha256:d0274768919e75f1c6f76b6720e7612282229f7af11f050d76cc8a29bf4b85b9" + }, + "observation_contract_version": "RAW-OBS-V0", + "observed_at": "2026-09-05T12:00:00Z", + "policy_version": "devostasis.policy.v1", + "previous_bundle_id": null, + "project_identity": { + "default_branch": "master", + "display_locator": "acme/widget", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "provider": "github" + }, + "project_key": "github.com/acme/widget", + "receipt": { + "capability_notes": [ + "CHECKS_SURFACE_NOT_COLLECTED", + "CI_SURFACE:GITHUB_ACTIONS_ONLY" + ], + "collector_version": "devostasis.github.v1", + "config_hash": "example", + "ended_at": "2026-09-05T12:00:00Z", + "per_key": { + "ci.configured": { + "freshness": "FRESH", + "status": "AVAILABLE" + }, + "ci.revision_verdicts_14d": { + "freshness": "FRESH", + "status": "AVAILABLE" + }, + "forge.change_requests.inventory": { + "freshness": "FRESH", + "status": "AVAILABLE" + }, + "forge.issues.inventory": { + "freshness": "FRESH", + "status": "AVAILABLE" + }, + "forge.releases.inventory": { + "freshness": "FRESH", + "status": "AVAILABLE" + }, + "forge.repository.metadata": { + "freshness": "FRESH", + "status": "AVAILABLE" + }, + "git.default_branch.commits_28d": { + "freshness": "FRESH", + "status": "AVAILABLE" + }, + "git.nondefault_branches.inventory": { + "freshness": "FRESH", + "status": "AVAILABLE" + }, + "planning.explicit_targets.inventory": { + "freshness": "FRESH", + "status": "AVAILABLE" + } + }, + "requested_keys": [ + "ci.configured", + "ci.revision_verdicts_14d", + "forge.change_requests.inventory", + "forge.issues.inventory", + "forge.releases.inventory", + "forge.repository.metadata", + "git.default_branch.commits_28d", + "git.nondefault_branches.inventory", + "planning.explicit_targets.inventory" + ], + "returned_keys": [ + "ci.configured", + "ci.revision_verdicts_14d", + "forge.change_requests.inventory", + "forge.issues.inventory", + "forge.releases.inventory", + "forge.repository.metadata", + "git.default_branch.commits_28d", + "git.nondefault_branches.inventory", + "planning.explicit_targets.inventory" + ], + "run_id": "run-20260905T120000Z-acme-widget", + "schema": "devostasis.receipt.v2", + "started_at": "2026-09-05T12:00:00Z", + "target": { + "default_branch": "master", + "display_locator": "acme/widget", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "owner": "acme", + "provider": "github", + "repo": "widget", + "visibility": "public" + } + }, + "renderer_version": "devostasis.render.v4", + "run_meta": { + "example": true, + "tool": { + "name": "devostasis", + "version": "0.2.0" + } + }, + "schema": "devostasis.manifest.v1", + "semantic_config": { + "debt_mapping": { + "labels": [ + "type:refactor" + ], + "mapping_version": "example-1", + "source": "labels" + }, + "planning": { + "link_marker": "Target:", + "path": null, + "source": "milestones" + } + }, + "supersedes_bundle_id": null, + "vitals_contract_version": "PV-VITALS-V1-002" +} diff --git a/examples/legacy/bundle-v2/observations.json b/examples/legacy/bundle-v2/observations.json new file mode 100644 index 0000000..415e73e --- /dev/null +++ b/examples/legacy/bundle-v2/observations.json @@ -0,0 +1,1795 @@ +{ + "observation_contract_version": "RAW-OBS-V0", + "observations": [ + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": null, + "evidence_ref": { + "example": true + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "ci.configured", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "example:ci.configured", + "status": "AVAILABLE", + "value": true, + "value_type": "boolean" + }, + { + "adapter_version": "devostasis.ci-history.v2", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": null, + "evidence_ref": null, + "freshness": "FRESH", + "notes": null, + "observation_id": "ci.revision_history_carried", + "provider": "history", + "reason_code": "NO_PREVIOUS_BUNDLE", + "schema_version": "RAW-OBS-V0", + "source_ref": "store:none", + "status": "UNAVAILABLE", + "value": null, + "value_type": "record" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "attempts_complete": true, + "outcome_map_version": "devostasis.ci-outcomes.github.v1", + "runs_complete": true, + "surface": "github_actions", + "window_end": "2026-09-05T12:00:00Z", + "window_start": "2026-08-22T12:00:00Z" + }, + "evidence_ref": { + "example": true + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "ci.revision_verdicts_14d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "example:ci.revision_verdicts_14d", + "status": "AVAILABLE", + "value": [ + { + "committed_at": "2026-08-22T09:00:00Z", + "current_verdict": "VERIFY_PASS", + "historical_contribution": "VERIFY_PASS", + "history_complete": true, + "history_provenance": "ATTEMPT_LEVEL", + "history_state": "PASS_ONLY_OBSERVED", + "parents": [ + { + "attempts_complete": true, + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "event": "push", + "history_state": "PASS_ONLY_OBSERVED", + "kind": "github_actions_workflow_run", + "name": "CI", + "parent_id": "github_actions:workflow_run:900", + "url": "https://example.invalid/actions/runs/900" + } + ], + "revision": "000000000000000000000000000000000000000c" + }, + { + "committed_at": "2026-08-23T09:00:00Z", + "current_verdict": "VERIFY_PASS", + "historical_contribution": "VERIFY_PASS", + "history_complete": true, + "history_provenance": "ATTEMPT_LEVEL", + "history_state": "PASS_ONLY_OBSERVED", + "parents": [ + { + "attempts_complete": true, + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "event": "push", + "history_state": "PASS_ONLY_OBSERVED", + "kind": "github_actions_workflow_run", + "name": "CI", + "parent_id": "github_actions:workflow_run:901", + "url": "https://example.invalid/actions/runs/900" + } + ], + "revision": "000000000000000000000000000000000000000d" + }, + { + "committed_at": "2026-08-24T09:00:00Z", + "current_verdict": "VERIFY_PASS", + "historical_contribution": "VERIFY_PASS", + "history_complete": true, + "history_provenance": "ATTEMPT_LEVEL", + "history_state": "PASS_ONLY_OBSERVED", + "parents": [ + { + "attempts_complete": true, + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "event": "push", + "history_state": "PASS_ONLY_OBSERVED", + "kind": "github_actions_workflow_run", + "name": "CI", + "parent_id": "github_actions:workflow_run:902", + "url": "https://example.invalid/actions/runs/900" + } + ], + "revision": "000000000000000000000000000000000000000e" + }, + { + "committed_at": "2026-08-25T09:00:00Z", + "current_verdict": "VERIFY_PASS", + "historical_contribution": "VERIFY_FAIL", + "history_complete": true, + "history_provenance": "ATTEMPT_LEVEL", + "history_state": "FAILURE_OBSERVED", + "parents": [ + { + "attempts_complete": true, + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_FAIL" + }, + { + "attempt": 2, + "state": "VERIFY_PASS" + } + ], + "current_attempt": 2, + "current_state": "VERIFY_PASS", + "event": "push", + "history_state": "FAILURE_OBSERVED", + "kind": "github_actions_workflow_run", + "name": "CI", + "parent_id": "github_actions:workflow_run:903", + "url": "https://example.invalid/actions/runs/900" + } + ], + "revision": "000000000000000000000000000000000000000f" + }, + { + "committed_at": "2026-08-26T09:00:00Z", + "current_verdict": "VERIFY_PASS", + "historical_contribution": "VERIFY_PASS", + "history_complete": true, + "history_provenance": "ATTEMPT_LEVEL", + "history_state": "PASS_ONLY_OBSERVED", + "parents": [ + { + "attempts_complete": true, + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "event": "push", + "history_state": "PASS_ONLY_OBSERVED", + "kind": "github_actions_workflow_run", + "name": "CI", + "parent_id": "github_actions:workflow_run:904", + "url": "https://example.invalid/actions/runs/900" + } + ], + "revision": "0000000000000000000000000000000000000010" + }, + { + "committed_at": "2026-08-27T09:00:00Z", + "current_verdict": "VERIFY_PASS", + "historical_contribution": "VERIFY_PASS", + "history_complete": true, + "history_provenance": "ATTEMPT_LEVEL", + "history_state": "PASS_ONLY_OBSERVED", + "parents": [ + { + "attempts_complete": true, + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "event": "push", + "history_state": "PASS_ONLY_OBSERVED", + "kind": "github_actions_workflow_run", + "name": "CI", + "parent_id": "github_actions:workflow_run:905", + "url": "https://example.invalid/actions/runs/900" + } + ], + "revision": "0000000000000000000000000000000000000011" + }, + { + "committed_at": "2026-08-28T09:00:00Z", + "current_verdict": "VERIFY_PASS", + "historical_contribution": "VERIFY_PASS", + "history_complete": true, + "history_provenance": "ATTEMPT_LEVEL", + "history_state": "PASS_ONLY_OBSERVED", + "parents": [ + { + "attempts_complete": true, + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "event": "push", + "history_state": "PASS_ONLY_OBSERVED", + "kind": "github_actions_workflow_run", + "name": "CI", + "parent_id": "github_actions:workflow_run:906", + "url": "https://example.invalid/actions/runs/900" + } + ], + "revision": "0000000000000000000000000000000000000012" + }, + { + "committed_at": "2026-08-29T09:00:00Z", + "current_verdict": "VERIFY_PASS", + "historical_contribution": "VERIFY_PASS", + "history_complete": true, + "history_provenance": "ATTEMPT_LEVEL", + "history_state": "PASS_ONLY_OBSERVED", + "parents": [ + { + "attempts_complete": true, + "attempts_observed": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "current_attempt": 1, + "current_state": "VERIFY_PASS", + "event": "push", + "history_state": "PASS_ONLY_OBSERVED", + "kind": "github_actions_workflow_run", + "name": "CI", + "parent_id": "github_actions:workflow_run:907", + "url": "https://example.invalid/actions/runs/900" + } + ], + "revision": "0000000000000000000000000000000000000013" + } + ], + "value_type": "series" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.issues.inventory", + "debt.mapping" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "debt.items.closed_count_28d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.issues.inventory", + "status": "AVAILABLE", + "value": 1, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.issues.inventory", + "debt.mapping" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "debt.items.open_count", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.issues.inventory", + "status": "AVAILABLE", + "value": 1, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.issues.inventory", + "debt.mapping" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "debt.items.open_stale_count_30d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.issues.inventory", + "status": "AVAILABLE", + "value": 0, + "value_type": "count" + }, + { + "adapter_version": "config", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": null, + "evidence_ref": null, + "freshness": "FRESH", + "notes": null, + "observation_id": "debt.mapping", + "provider": "config", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "config:debt", + "status": "AVAILABLE", + "value": { + "labels": [ + "type:refactor" + ], + "mapping_version": "example-1", + "source": "labels" + }, + "value_type": "record" + }, + { + "adapter_version": "config", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": null, + "evidence_ref": null, + "freshness": "FRESH", + "notes": null, + "observation_id": "debt.registry.capability", + "provider": "config", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "config:debt", + "status": "AVAILABLE", + "value": "CONFIGURED", + "value_type": "enum" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + }, + "evidence_ref": { + "example": true + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "forge.change_requests.inventory", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "example:forge.change_requests.inventory", + "status": "AVAILABLE", + "value": [ + { + "author": "dev", + "closed_at": null, + "created_at": "2026-09-02T10:00:00Z", + "draft": false, + "id": 40, + "linkage_unresolved": [], + "merged_at": null, + "number": 40, + "state": "OPEN", + "target_id": "3", + "target_refs": [ + { + "state": "OPEN", + "target_id": "3" + } + ], + "target_state": "OPEN", + "title": "Refactor the pricing module", + "updated_at": "2026-09-04T10:00:00Z", + "url": "https://example.invalid/pull/40" + }, + { + "author": "dev", + "closed_at": null, + "created_at": "2026-09-03T10:00:00Z", + "draft": true, + "id": 41, + "linkage_unresolved": [], + "merged_at": null, + "number": 41, + "state": "OPEN", + "target_id": null, + "target_refs": [], + "target_state": null, + "title": "Add the export button", + "updated_at": "2026-09-05T08:00:00Z", + "url": "https://example.invalid/pull/41" + }, + { + "author": "dev", + "closed_at": "2026-08-14T06:00:00Z", + "created_at": "2026-08-13T10:00:00Z", + "draft": false, + "id": 31, + "linkage_unresolved": [], + "merged_at": "2026-08-14T06:00:00Z", + "number": 31, + "state": "MERGED", + "target_id": "3", + "target_refs": [ + { + "state": "OPEN", + "target_id": "3" + } + ], + "target_state": "OPEN", + "title": "Merged change 1", + "updated_at": "2026-08-14T10:00:00Z", + "url": "https://example.invalid/pull/31" + }, + { + "author": "dev", + "closed_at": "2026-08-15T06:00:00Z", + "created_at": "2026-08-14T10:00:00Z", + "draft": false, + "id": 32, + "linkage_unresolved": [], + "merged_at": "2026-08-15T06:00:00Z", + "number": 32, + "state": "MERGED", + "target_id": "2", + "target_refs": [ + { + "state": "CLOSED", + "target_id": "2" + } + ], + "target_state": "CLOSED", + "title": "Merged change 2", + "updated_at": "2026-08-15T10:00:00Z", + "url": "https://example.invalid/pull/32" + }, + { + "author": "dev", + "closed_at": "2026-08-16T06:00:00Z", + "created_at": "2026-08-15T10:00:00Z", + "draft": false, + "id": 33, + "linkage_unresolved": [], + "merged_at": "2026-08-16T06:00:00Z", + "number": 33, + "state": "MERGED", + "target_id": "3", + "target_refs": [ + { + "state": "OPEN", + "target_id": "3" + } + ], + "target_state": "OPEN", + "title": "Merged change 3", + "updated_at": "2026-08-16T10:00:00Z", + "url": "https://example.invalid/pull/33" + }, + { + "author": "dev", + "closed_at": "2026-08-17T06:00:00Z", + "created_at": "2026-08-16T10:00:00Z", + "draft": false, + "id": 34, + "linkage_unresolved": [], + "merged_at": "2026-08-17T06:00:00Z", + "number": 34, + "state": "MERGED", + "target_id": null, + "target_refs": [], + "target_state": null, + "title": "Merged change 4", + "updated_at": "2026-08-17T10:00:00Z", + "url": "https://example.invalid/pull/34" + }, + { + "author": "dev", + "closed_at": "2026-08-18T06:00:00Z", + "created_at": "2026-08-17T10:00:00Z", + "draft": false, + "id": 35, + "linkage_unresolved": [], + "merged_at": "2026-08-18T06:00:00Z", + "number": 35, + "state": "MERGED", + "target_id": "3", + "target_refs": [ + { + "state": "OPEN", + "target_id": "3" + } + ], + "target_state": "OPEN", + "title": "Merged change 5", + "updated_at": "2026-08-18T10:00:00Z", + "url": "https://example.invalid/pull/35" + }, + { + "author": "dev", + "closed_at": "2026-08-19T06:00:00Z", + "created_at": "2026-08-18T10:00:00Z", + "draft": false, + "id": 36, + "linkage_unresolved": [], + "merged_at": "2026-08-19T06:00:00Z", + "number": 36, + "state": "MERGED", + "target_id": null, + "target_refs": [], + "target_state": null, + "title": "Merged change 6", + "updated_at": "2026-08-19T10:00:00Z", + "url": "https://example.invalid/pull/36" + }, + { + "author": "dev", + "closed_at": "2026-08-20T06:00:00Z", + "created_at": "2026-08-19T10:00:00Z", + "draft": false, + "id": 37, + "linkage_unresolved": [], + "merged_at": "2026-08-20T06:00:00Z", + "number": 37, + "state": "MERGED", + "target_id": "3", + "target_refs": [ + { + "state": "OPEN", + "target_id": "3" + } + ], + "target_state": "OPEN", + "title": "Merged change 7", + "updated_at": "2026-08-20T10:00:00Z", + "url": "https://example.invalid/pull/37" + }, + { + "author": "dev", + "closed_at": "2026-08-21T06:00:00Z", + "created_at": "2026-08-20T10:00:00Z", + "draft": false, + "id": 38, + "linkage_unresolved": [], + "merged_at": "2026-08-21T06:00:00Z", + "number": 38, + "state": "MERGED", + "target_id": null, + "target_refs": [], + "target_state": null, + "title": "Merged change 8", + "updated_at": "2026-08-21T10:00:00Z", + "url": "https://example.invalid/pull/38" + } + ], + "value_type": "series" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.change_requests.inventory", + "forge.change_requests.median_time_to_merge_seconds_28d" + ] + }, + "freshness": "FRESH", + "notes": "whole-hour projection of median_time_to_merge_seconds_28d for presentation; never a classifier input", + "observation_id": "forge.change_requests.median_time_to_merge_hours_28d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.change_requests.inventory", + "status": "AVAILABLE", + "value": 20, + "value_type": "duration" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.change_requests.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "forge.change_requests.median_time_to_merge_seconds_28d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.change_requests.inventory", + "status": "AVAILABLE", + "value": { + "denominator": 1, + "numerator": 72000 + }, + "value_type": "duration" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.change_requests.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "forge.change_requests.merged_count_28d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.change_requests.inventory", + "status": "AVAILABLE", + "value": 8, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.change_requests.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "forge.change_requests.oldest_open_age_days", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.change_requests.inventory", + "status": "AVAILABLE", + "value": 3, + "value_type": "duration" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.change_requests.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "forge.change_requests.open_count", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.change_requests.inventory", + "status": "AVAILABLE", + "value": 2, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.change_requests.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "forge.change_requests.stale_open_count_14d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.change_requests.inventory", + "status": "AVAILABLE", + "value": 0, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.change_requests.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "forge.change_requests.updated_count_28d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.change_requests.inventory", + "status": "AVAILABLE", + "value": 10, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + }, + "evidence_ref": { + "example": true + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "forge.issues.inventory", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "example:forge.issues.inventory", + "status": "AVAILABLE", + "value": [ + { + "author": "user", + "closed_at": null, + "created_at": "2026-05-01T10:00:00Z", + "id": 5, + "labels": [ + "enhancement" + ], + "number": 5, + "state": "OPEN", + "target_id": null, + "title": "Old feature request", + "updated_at": "2026-06-01T10:00:00Z", + "url": "https://example.invalid/issues/5" + }, + { + "author": "dev", + "closed_at": null, + "created_at": "2026-08-20T10:00:00Z", + "id": 12, + "labels": [ + "type:refactor" + ], + "number": 12, + "state": "OPEN", + "target_id": "3", + "title": "Replace the legacy parser", + "updated_at": "2026-09-01T10:00:00Z", + "url": "https://example.invalid/issues/12" + }, + { + "author": "user", + "closed_at": null, + "created_at": "2026-09-04T10:00:00Z", + "id": 13, + "labels": [ + "bug" + ], + "number": 13, + "state": "OPEN", + "target_id": null, + "title": "Crash on empty input", + "updated_at": "2026-09-04T12:00:00Z", + "url": "https://example.invalid/issues/13" + }, + { + "author": "dev", + "closed_at": "2026-08-25T10:00:00Z", + "created_at": "2026-08-01T10:00:00Z", + "id": 11, + "labels": [ + "type:refactor" + ], + "number": 11, + "state": "CLOSED", + "target_id": null, + "title": "Remove dead configuration flags", + "updated_at": "2026-08-25T10:00:00Z", + "url": "https://example.invalid/issues/11" + } + ], + "value_type": "series" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.issues.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "forge.issues.open_count", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.issues.inventory", + "status": "AVAILABLE", + "value": 3, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.issues.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "forge.issues.stale_open_count_30d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.issues.inventory", + "status": "AVAILABLE", + "value": 1, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.issues.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "forge.issues.updated_count_28d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.issues.inventory", + "status": "AVAILABLE", + "value": 3, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "limit": 30, + "recent_only": true + }, + "evidence_ref": { + "example": true + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "forge.releases.inventory", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "example:forge.releases.inventory", + "status": "AVAILABLE", + "value": [ + { + "name": "Release 1.1", + "prerelease": false, + "published_at": "2026-08-16T10:00:00Z", + "tag": "v1.1.0", + "url": "https://example.invalid/releases/v1.1.0" + } + ], + "value_type": "series" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": null, + "evidence_ref": { + "example": true + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "forge.repository.metadata", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "example:forge.repository.metadata", + "status": "AVAILABLE", + "value": { + "archived": false, + "default_branch": "master", + "full_name": "acme/widget", + "has_issues": true, + "html_url": "https://example.invalid/acme/widget", + "id": 123456, + "pushed_at": "2026-09-05T08:00:00Z", + "visibility": "public" + }, + "value_type": "record" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "branch": "master", + "complete": true, + "window_end": "2026-09-05T12:00:00Z", + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "git.default_branch.commits_28d" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "git.default_branch.commit_active_days_28d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:git.default_branch.commits_28d", + "status": "AVAILABLE", + "value": 19, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "branch": "master", + "complete": true, + "window_end": "2026-09-05T12:00:00Z", + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "git.default_branch.commits_28d" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "git.default_branch.commits.count_28d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:git.default_branch.commits_28d", + "status": "AVAILABLE", + "value": 19, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "branch": "master", + "complete": true, + "window_end": "2026-09-05T12:00:00Z", + "window_start": "2026-08-08T12:00:00Z" + }, + "evidence_ref": { + "example": true + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "git.default_branch.commits_28d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "example:git.default_branch.commits_28d", + "status": "AVAILABLE", + "value": [ + { + "committed_at": "2026-08-11T09:00:00Z", + "sha": "0000000000000000000000000000000000000001", + "title": "feat: change number 1" + }, + { + "committed_at": "2026-08-12T09:00:00Z", + "sha": "0000000000000000000000000000000000000002", + "title": "feat: change number 2" + }, + { + "committed_at": "2026-08-13T09:00:00Z", + "sha": "0000000000000000000000000000000000000003", + "title": "feat: change number 3" + }, + { + "committed_at": "2026-08-14T09:00:00Z", + "sha": "0000000000000000000000000000000000000004", + "title": "feat: change number 4" + }, + { + "committed_at": "2026-08-15T09:00:00Z", + "sha": "0000000000000000000000000000000000000005", + "title": "feat: change number 5" + }, + { + "committed_at": "2026-08-16T09:00:00Z", + "sha": "0000000000000000000000000000000000000006", + "title": "feat: change number 6" + }, + { + "committed_at": "2026-08-17T09:00:00Z", + "sha": "0000000000000000000000000000000000000007", + "title": "feat: change number 7" + }, + { + "committed_at": "2026-08-18T09:00:00Z", + "sha": "0000000000000000000000000000000000000008", + "title": "feat: change number 8" + }, + { + "committed_at": "2026-08-19T09:00:00Z", + "sha": "0000000000000000000000000000000000000009", + "title": "feat: change number 9" + }, + { + "committed_at": "2026-08-20T09:00:00Z", + "sha": "000000000000000000000000000000000000000a", + "title": "feat: change number 10" + }, + { + "committed_at": "2026-08-21T09:00:00Z", + "sha": "000000000000000000000000000000000000000b", + "title": "feat: change number 11" + }, + { + "committed_at": "2026-08-22T09:00:00Z", + "sha": "000000000000000000000000000000000000000c", + "title": "feat: change number 12" + }, + { + "committed_at": "2026-08-23T09:00:00Z", + "sha": "000000000000000000000000000000000000000d", + "title": "feat: change number 13" + }, + { + "committed_at": "2026-08-24T09:00:00Z", + "sha": "000000000000000000000000000000000000000e", + "title": "feat: change number 14" + }, + { + "committed_at": "2026-08-25T09:00:00Z", + "sha": "000000000000000000000000000000000000000f", + "title": "feat: change number 15" + }, + { + "committed_at": "2026-08-26T09:00:00Z", + "sha": "0000000000000000000000000000000000000010", + "title": "feat: change number 16" + }, + { + "committed_at": "2026-08-27T09:00:00Z", + "sha": "0000000000000000000000000000000000000011", + "title": "feat: change number 17" + }, + { + "committed_at": "2026-08-28T09:00:00Z", + "sha": "0000000000000000000000000000000000000012", + "title": "feat: change number 18" + }, + { + "committed_at": "2026-08-29T09:00:00Z", + "sha": "0000000000000000000000000000000000000013", + "title": "feat: change number 19" + } + ], + "value_type": "series" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "head_lookups": 2, + "heads_resolved": true + }, + "evidence_ref": { + "example": true + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "git.nondefault_branches.inventory", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "example:git.nondefault_branches.inventory", + "status": "AVAILABLE", + "value": [ + { + "head_committed_at": "2026-06-15T10:00:00Z", + "head_sha": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "name": "experiment/old-idea", + "protected": false + }, + { + "head_committed_at": "2026-09-05T08:00:00Z", + "head_sha": "ffffffffffffffffffffffffffffffffffffffff", + "name": "feature/export", + "protected": false + } + ], + "value_type": "series" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "complete": true, + "head_lookups": 2, + "heads_resolved": true + } + }, + "evidence_ref": { + "derived_from": [ + "git.nondefault_branches.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "git.nondefault_branches.stale_count_30d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:git.nondefault_branches.inventory", + "status": "AVAILABLE", + "value": 1, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": null, + "evidence_ref": { + "derived_from": [ + "planning.explicit_targets.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "planning.explicit_targets.capability", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:planning.explicit_targets.inventory", + "status": "AVAILABLE", + "value": "SUPPORTED", + "value_type": "enum" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source": "milestones" + }, + "evidence_ref": { + "example": true + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "planning.explicit_targets.inventory", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "example:planning.explicit_targets.inventory", + "status": "AVAILABLE", + "value": [ + { + "closed_items": 4, + "due_at": "2026-10-31T00:00:00Z", + "id": 3, + "open_items": 3, + "state": "OPEN", + "target_id": "3", + "title": "Release 1.2", + "url": "https://example.invalid/milestone/3" + }, + { + "closed_items": 9, + "due_at": "2026-08-15T00:00:00Z", + "id": 2, + "open_items": 0, + "state": "CLOSED", + "target_id": "2", + "title": "Release 1.1", + "url": "https://example.invalid/milestone/2" + } + ], + "value_type": "series" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "complete": true, + "source": "milestones" + } + }, + "evidence_ref": { + "derived_from": [ + "planning.explicit_targets.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "planning.explicit_targets.nearest_future_boundary_days", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:planning.explicit_targets.inventory", + "status": "AVAILABLE", + "value": 55, + "value_type": "duration" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "complete": true, + "source": "milestones" + } + }, + "evidence_ref": { + "derived_from": [ + "planning.explicit_targets.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "planning.explicit_targets.open_beyond_28d_count", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:planning.explicit_targets.inventory", + "status": "AVAILABLE", + "value": 1, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "complete": true, + "source": "milestones" + } + }, + "evidence_ref": { + "derived_from": [ + "planning.explicit_targets.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "planning.explicit_targets.open_count", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:planning.explicit_targets.inventory", + "status": "AVAILABLE", + "value": 1, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "complete": true, + "source": "milestones" + } + }, + "evidence_ref": { + "derived_from": [ + "planning.explicit_targets.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "planning.explicit_targets.open_with_future_boundary_count", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:planning.explicit_targets.inventory", + "status": "AVAILABLE", + "value": 1, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.change_requests.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "planning.linkage.active_change_requests_count_28d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.change_requests.inventory", + "status": "AVAILABLE", + "value": 10, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.change_requests.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "planning.linkage.active_change_requests_linked_count_28d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.change_requests.inventory", + "status": "AVAILABLE", + "value": 6, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.change_requests.inventory" + ] + }, + "freshness": "FRESH", + "notes": "active change requests linked to a target that is open now; presentation only since direction.bands.v2, never a classifier input", + "observation_id": "planning.linkage.active_change_requests_linked_to_open_target_count_28d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.change_requests.inventory", + "status": "AVAILABLE", + "value": 5, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.change_requests.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "planning.linkage.active_change_requests_unlinked_count_28d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.change_requests.inventory", + "status": "AVAILABLE", + "value": 4, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.change_requests.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "planning.linkage.active_change_requests_unresolved_count_28d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.change_requests.inventory", + "status": "AVAILABLE", + "value": 0, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.change_requests.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "planning.linkage.links_per_target_28d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.change_requests.inventory", + "status": "AVAILABLE", + "value": { + "2": 1, + "3": 5 + }, + "value_type": "record" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.change_requests.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "planning.linkage.missing_target_reference_count_28d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.change_requests.inventory", + "status": "AVAILABLE", + "value": 0, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.change_requests.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "planning.linkage.unknown_target_reference_count_28d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.change_requests.inventory", + "status": "AVAILABLE", + "value": 0, + "value_type": "count" + }, + { + "adapter_version": "devostasis.github.v1", + "collected_at": "2026-09-05T12:00:00Z", + "coverage": { + "complete": true, + "source_coverage": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + } + }, + "evidence_ref": { + "derived_from": [ + "forge.change_requests.inventory" + ] + }, + "freshness": "FRESH", + "notes": null, + "observation_id": "planning.linkage.unresolved_change_requests_28d", + "provider": "github", + "reason_code": null, + "schema_version": "RAW-OBS-V0", + "source_ref": "derived:forge.change_requests.inventory", + "status": "AVAILABLE", + "value": {}, + "value_type": "record" + } + ], + "observed_at": "2026-09-05T12:00:00Z", + "receipt": { + "capability_notes": [ + "CHECKS_SURFACE_NOT_COLLECTED", + "CI_SURFACE:GITHUB_ACTIONS_ONLY" + ], + "collector_version": "devostasis.github.v1", + "config_hash": "example", + "ended_at": "2026-09-05T12:00:00Z", + "per_key": { + "ci.configured": { + "freshness": "FRESH", + "status": "AVAILABLE" + }, + "ci.revision_verdicts_14d": { + "freshness": "FRESH", + "status": "AVAILABLE" + }, + "forge.change_requests.inventory": { + "freshness": "FRESH", + "status": "AVAILABLE" + }, + "forge.issues.inventory": { + "freshness": "FRESH", + "status": "AVAILABLE" + }, + "forge.releases.inventory": { + "freshness": "FRESH", + "status": "AVAILABLE" + }, + "forge.repository.metadata": { + "freshness": "FRESH", + "status": "AVAILABLE" + }, + "git.default_branch.commits_28d": { + "freshness": "FRESH", + "status": "AVAILABLE" + }, + "git.nondefault_branches.inventory": { + "freshness": "FRESH", + "status": "AVAILABLE" + }, + "planning.explicit_targets.inventory": { + "freshness": "FRESH", + "status": "AVAILABLE" + } + }, + "requested_keys": [ + "ci.configured", + "ci.revision_verdicts_14d", + "forge.change_requests.inventory", + "forge.issues.inventory", + "forge.releases.inventory", + "forge.repository.metadata", + "git.default_branch.commits_28d", + "git.nondefault_branches.inventory", + "planning.explicit_targets.inventory" + ], + "returned_keys": [ + "ci.configured", + "ci.revision_verdicts_14d", + "forge.change_requests.inventory", + "forge.issues.inventory", + "forge.releases.inventory", + "forge.repository.metadata", + "git.default_branch.commits_28d", + "git.nondefault_branches.inventory", + "planning.explicit_targets.inventory" + ], + "run_id": "run-20260905T120000Z-acme-widget", + "schema": "devostasis.receipt.v2", + "started_at": "2026-09-05T12:00:00Z", + "target": { + "default_branch": "master", + "display_locator": "acme/widget", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "owner": "acme", + "provider": "github", + "repo": "widget", + "visibility": "public" + } + }, + "schema": "devostasis.observations.v1", + "subject": { + "default_branch": "master", + "display_locator": "acme/widget", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "owner": "acme", + "provider": "github", + "repo": "widget", + "visibility": "public" + } +} diff --git a/examples/legacy/bundle-v2/report.md b/examples/legacy/bundle-v2/report.md new file mode 100644 index 0000000..284a3fd --- /dev/null +++ b/examples/legacy/bundle-v2/report.md @@ -0,0 +1,224 @@ +# Devostasis report: acme/widget + +- Observed at: 2026-09-05T12:00:00Z +- Comparison: BASELINE +- Bundle: `422f6c269728988da2808817b5e72db3f3df97a330fb91c52f3f53e6ee76554c` +- Contracts: vitals PV-VITALS-V1-002, observations RAW-OBS-V0, policy devostasis.policy.v1 + +```text +Horizon ████████░░ 84 EXTENDED +Clutter █░░░░░░░░░ 10 LIGHT +Direction ██████░░░░ 60 MIXED +Flow █░░░░░░░░░ 10 MOVING +Integrity ██████░░░░ 62 FLAKY +Debt █░░░░░░░░░ 11 PRESENT +Pulse ████████░░ 82 SURGING +``` + +Bands are the canonical states. Gauges are the versioned 0-100 normalization (devostasis.gauge.v1) of each band on the scale of the phenomenon it describes (activity, queue pressure, verification stability, residue, declared future work, traceability share, registered debt). UNKNOWN means evidence was insufficient; DEGRADED means a conservative bound, shown as ≥ or ~. + +## Attention + +Demand levels come from mapping `devostasis-default-1` (devostasis.demand.v2); inside a level the canonical Vital order applies and gauges are never compared across Vitals. There is no aggregate. + +| Order | Vital | Level | Band | Gauge | +| --- | --- | --- | --- | --- | +| 1 | Integrity | HIGH | FLAKY | 62 | +| 2 | Direction | MEDIUM | MIXED | 60 | +| 3 | Debt | MEDIUM | PRESENT | 11 | +| 4 | Clutter | LOW | LIGHT | 10 | +| 5 | Flow | LOW | MOVING | 10 | +| 6 | Horizon | MINIMAL | EXTENDED | 84 | +| 7 | Pulse | MINIMAL | SURGING | 82 | + +## Vitals + +| Vital | Gauge | Band | Evaluation | Semantics | Explanation | +| --- | --- | --- | --- | --- | --- | +| Horizon | 84 | EXTENDED | AVAILABLE | EXACT | 1 open planning targets, 1 with a future boundary, 1 reaching beyond 28 days. | +| Clutter | 10 | LIGHT | AVAILABLE | EXACT | 1 stale work items out of 5 tracked open items; 1 stale non-default branches. | +| Direction | 60 | MIXED | AVAILABLE | EXACT | 6 of 10 active change requests are explicitly linked to a declared target, open or closed. | +| Flow | 10 | MOVING | AVAILABLE | EXACT | 2 open change requests; 8 merged in 28 days; oldest open for 3 days; median time to merge 20h 0m. | +| Integrity | 62 | FLAKY | AVAILABLE | EXACT | 1 of 8 decisive revisions failed verification in 14 days; latest decisive verdict is VERIFY_PASS. | +| Debt | 11 | PRESENT | AVAILABLE | EXACT | 1 open registered debt items under mapping version example-1. | +| Pulse | 82 | SURGING | AVAILABLE | EXACT | 19 default-branch commits on 19 active days and 32 activity events across 3 channels in 28 days. | + +### Horizon: EXTENDED + +_Is future work explicitly declared, and does any declaration reach beyond 28 days?_ + +- Evaluation: AVAILABLE; rule `horizon.bands.v2` +- Gauge (declared future work): ████████░░ 84 +- Shares signals with: HORIZON_DIRECTION_PLANNING + +| Metric | Value | +| --- | --- | +| capability | SUPPORTED | +| nearest_future_boundary_days | 55 | +| open_beyond_28d_count | 1 | +| open_count | 1 | +| open_with_future_boundary_count | 1 | + +### Clutter: LIGHT + +_How much unresolved stale residue is observable?_ + +- Evaluation: AVAILABLE; rule `clutter.bands.v1` +- Gauge (stale residue): █░░░░░░░░░ 10 +- Shares signals with: CLUTTER_FLOW_FORGE + +| Metric | Value | +| --- | --- | +| stale_branch_count | 1 | +| stale_work_count | 1 | +| stale_work_ratio | 0.20 (1/5) | +| tracked_open_count | 5 | + +### Direction: MIXED + +_Is active change work explicitly traceable to declared targets?_ + +- Evaluation: AVAILABLE; rule `direction.bands.v2` +- Gauge (traceability share): ██████░░░░ 60 +- Shares signals with: HORIZON_DIRECTION_PLANNING, DIRECTION_PULSE_ACTIVITY + +| Metric | Value | +| --- | --- | +| active_change_count_28d | 10 | +| capability | SUPPORTED | +| linkage_rule | PV-REV-DIRECTION-CLOSED-TARGET-001 | +| linked_active_change_count_28d | 6 | +| linked_to_open_target_count_28d | 5 | +| missing_target_reference_count_28d | 0 | +| unlinked_active_change_count_28d | 4 | +| unresolved_active_change_count_28d | 0 | + +### Flow: MOVING + +_What is the state and friction of the current change-request queue?_ + +- Evaluation: AVAILABLE; rule `flow.bands.v1` +- Gauge (queue pressure): █░░░░░░░░░ 10 +- Shares signals with: CLUTTER_FLOW_FORGE, FLOW_PULSE_ACTIVITY + +| Metric | Value | +| --- | --- | +| median_time_to_merge_hours_28d | 20 | +| median_time_to_merge_seconds_28d | 72000.00 (72000/1) | +| merged_count_28d | 8 | +| oldest_open_age_days | 3 | +| open_count | 2 | + +### Integrity: FLAKY + +_What does automated verification say about recent immutable revisions?_ + +- Evaluation: AVAILABLE; rule `integrity.bands.v1+ci-unit-004+hist-002` +- Gauge (verification stability): ██████░░░░ 62 +- Shares signals with: INTEGRITY_ONLY + +| Metric | Value | +| --- | --- | +| decisive_count_14d | 8 | +| failed_count_14d | 1 | +| failure_ratio_14d | 0.13 (1/8) | +| history_rule | PV-HIST-002 | +| revisions_in_window | 8 | +| revisions_with_history | 8 | +| revisions_with_verification | 8 | +| sample_strength | ESTABLISHED | +| unknown_history_count_14d | 0 | + +### Debt: PRESENT + +_How much explicitly registered maintenance obligation is unresolved?_ + +- Evaluation: AVAILABLE; rule `debt.bands.v2` +- Gauge (registered debt): █░░░░░░░░░ 11 +- Shares signals with: DEBT_CLUTTER_MAINTENANCE + +| Metric | Value | +| --- | --- | +| capability | CONFIGURED | +| closed_count_28d | 1 | +| open_count | 1 | +| open_stale_count_30d | 0 | + +### Pulse: SURGING + +_How intense is recent observable activity?_ + +- Evaluation: AVAILABLE; rule `pulse.bands.v1` +- Gauge (activity intensity): ████████░░ 82 +- Shares signals with: FLOW_PULSE_ACTIVITY, DIRECTION_PULSE_ACTIVITY + +| Metric | Value | +| --- | --- | +| activity_events_28d | 32 | +| channel_count | 3 | +| commit_active_days_28d | 19 | +| commits_28d | 19 | + +## Observability + +Every requested observation was available and fresh. + +Capability notes: +- `CHECKS_SURFACE_NOT_COLLECTED` +- `CI_SURFACE:GITHUB_ACTIONS_ONLY` + +## Changes since previous bundle + +This is the first canonical bundle for the project: there is no previous state to compare against. + +## Activity + +Interval over the trailing 28-day observation window (no previous bundle): (2026-08-08T12:00:00Z, 2026-09-05T12:00:00Z]. + +| Class | Counts | +| --- | --- | +| Revisions on default branch | 19 | +| Change requests | opened 10, merged 8, closed 0 | +| Work items | opened 2, closed 1 | +| Verification | 8 revisions verified, 1 with an observed failure, 0 unresolved | +| Releases | 1 | +| Capability changes | 0 | + +Change requests: +- OPENED #31 Merged change 1 (2026-08-13T10:00:00Z) +- MERGED #31 Merged change 1 (2026-08-14T06:00:00Z) +- OPENED #32 Merged change 2 (2026-08-14T10:00:00Z) +- MERGED #32 Merged change 2 (2026-08-15T06:00:00Z) +- OPENED #33 Merged change 3 (2026-08-15T10:00:00Z) +- MERGED #33 Merged change 3 (2026-08-16T06:00:00Z) +- OPENED #34 Merged change 4 (2026-08-16T10:00:00Z) +- MERGED #34 Merged change 4 (2026-08-17T06:00:00Z) +- OPENED #35 Merged change 5 (2026-08-17T10:00:00Z) +- MERGED #35 Merged change 5 (2026-08-18T06:00:00Z) +- OPENED #36 Merged change 6 (2026-08-18T10:00:00Z) +- MERGED #36 Merged change 6 (2026-08-19T06:00:00Z) +- OPENED #37 Merged change 7 (2026-08-19T10:00:00Z) +- MERGED #37 Merged change 7 (2026-08-20T06:00:00Z) +- OPENED #38 Merged change 8 (2026-08-20T10:00:00Z) +- MERGED #38 Merged change 8 (2026-08-21T06:00:00Z) +- OPENED #40 Refactor the pricing module (2026-09-02T10:00:00Z) +- OPENED #41 Add the export button (2026-09-03T10:00:00Z) + +Work items: +- OPENED #12 Replace the legacy parser (2026-08-20T10:00:00Z) +- CLOSED #11 Remove dead configuration flags (2026-08-25T10:00:00Z) +- OPENED #13 Crash on empty input (2026-09-04T10:00:00Z) + +Verification findings: +- 000000000000 current VERIFY_PASS, history FAILURE_OBSERVED (2026-08-25T09:00:00Z) + +Releases: +- v1.1.0 Release 1.1 (2026-08-16T10:00:00Z) + +## Provenance + +- Artifact contract: devostasis.bundle.v2; bundle identity: PV-BUNDLE-ID-002; renderer: devostasis.render.v4; gauges: devostasis.gauge.v1; demand: devostasis.demand.v2 +- Effective config digest: `sha256:cc472c9ad93f6721ecc9a3fc14f3067ccc85af33c2acceb4489982a44eb370ac` (config version `example-1`) +- Adapters: github devostasis.github.v1 +- Observations digest: `sha256:78603834c2e4cb90798cc144488c5b9e8d99407908269572a622ed13f1e58b33` +- Generated deterministically from the machine bundle without any language model. diff --git a/examples/legacy/bundle-v2/snapshot.json b/examples/legacy/bundle-v2/snapshot.json new file mode 100644 index 0000000..3bc19f3 --- /dev/null +++ b/examples/legacy/bundle-v2/snapshot.json @@ -0,0 +1,643 @@ +{ + "observation_contract_version": "RAW-OBS-V0", + "observations_digest": "sha256:78603834c2e4cb90798cc144488c5b9e8d99407908269572a622ed13f1e58b33", + "observed_at": "2026-09-05T12:00:00Z", + "policy_version": "devostasis.policy.v1", + "schema": "devostasis.snapshot.v1", + "subject": { + "default_branch": "master", + "display_locator": "acme/widget", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "owner": "acme", + "provider": "github", + "repo": "widget", + "visibility": "public" + }, + "vitals": [ + { + "band": "EXTENDED", + "band_semantics": "EXACT", + "dependency_group_ids": [ + "HORIZON_DIRECTION_PLANNING" + ], + "derived": { + "capability": "SUPPORTED", + "nearest_future_boundary_days": 55, + "open_beyond_28d_count": 1, + "open_count": 1, + "open_with_future_boundary_count": 1 + }, + "diagnostics": [], + "evaluation_status": "AVAILABLE", + "explanation": "1 open planning targets, 1 with a future boundary, 1 reaching beyond 28 days.", + "inputs": [ + { + "freshness": "FRESH", + "observation_id": "planning.explicit_targets.capability", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "planning.explicit_targets.open_count", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "planning.explicit_targets.open_with_future_boundary_count", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "planning.explicit_targets.open_beyond_28d_count", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "planning.explicit_targets.nearest_future_boundary_days", + "status": "AVAILABLE" + } + ], + "policy_version": "devostasis.policy.v1", + "possible_bands": null, + "rule_id": "horizon.bands.v2", + "shared_signal_groups": [ + "PLANNING_TARGETS" + ], + "vital_id": "horizon", + "vital_version": "PV-VITALS-V1-002/horizon", + "vitals_contract_version": "PV-VITALS-V1-002" + }, + { + "band": "LIGHT", + "band_semantics": "EXACT", + "dependency_group_ids": [ + "CLUTTER_FLOW_FORGE" + ], + "derived": { + "components": { + "branches": { + "acquisition": "COMPLETE", + "stale_30d": 1 + }, + "change_requests": { + "acquisition": "COMPLETE", + "open": 2, + "stale_14d": 0 + }, + "issues": { + "acquisition": "COMPLETE", + "open": 3, + "stale_30d": 1 + } + }, + "stale_branch_count": 1, + "stale_work_count": 1, + "stale_work_ratio": { + "den": 5, + "num": 1 + }, + "tracked_open_count": 5 + }, + "diagnostics": [], + "evaluation_status": "AVAILABLE", + "explanation": "1 stale work items out of 5 tracked open items; 1 stale non-default branches.", + "inputs": [ + { + "freshness": "FRESH", + "observation_id": "forge.issues.open_count", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "forge.issues.stale_open_count_30d", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "forge.change_requests.open_count", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "forge.change_requests.stale_open_count_14d", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "git.nondefault_branches.stale_count_30d", + "status": "AVAILABLE" + }, + { + "freshness": "UNKNOWN", + "observation_id": "git.nondefault_branches.retention_semantics", + "status": "NOT_REQUESTED" + } + ], + "policy_version": "devostasis.policy.v1", + "possible_bands": null, + "rule_id": "clutter.bands.v1", + "shared_signal_groups": [ + "FORGE_INVENTORY", + "BRANCH_RESIDUE" + ], + "vital_id": "clutter", + "vital_version": "PV-VITALS-V1-002/clutter", + "vitals_contract_version": "PV-VITALS-V1-002" + }, + { + "band": "MIXED", + "band_semantics": "EXACT", + "dependency_group_ids": [ + "HORIZON_DIRECTION_PLANNING", + "DIRECTION_PULSE_ACTIVITY" + ], + "derived": { + "active_change_count_28d": 10, + "capability": "SUPPORTED", + "linkage_rule": "PV-REV-DIRECTION-CLOSED-TARGET-001", + "linked_active_change_count_28d": 6, + "linked_to_open_target_count_28d": 5, + "links_per_target": { + "2": 1, + "3": 5 + }, + "missing_target_reference_count_28d": 0, + "unlinked_active_change_count_28d": 4, + "unresolved_active_change_count_28d": 0 + }, + "diagnostics": [], + "evaluation_status": "AVAILABLE", + "explanation": "6 of 10 active change requests are explicitly linked to a declared target, open or closed.", + "inputs": [ + { + "freshness": "FRESH", + "observation_id": "planning.linkage.active_change_requests_count_28d", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "planning.linkage.active_change_requests_linked_count_28d", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "planning.linkage.active_change_requests_unlinked_count_28d", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "planning.linkage.active_change_requests_unresolved_count_28d", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "planning.explicit_targets.capability", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "planning.linkage.links_per_target_28d", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "planning.linkage.missing_target_reference_count_28d", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "planning.linkage.unresolved_change_requests_28d", + "status": "AVAILABLE" + } + ], + "policy_version": "devostasis.policy.v1", + "possible_bands": null, + "rule_id": "direction.bands.v2", + "shared_signal_groups": [ + "PLANNING_TARGETS", + "CHANGE_REQUEST_ACTIVITY" + ], + "vital_id": "direction", + "vital_version": "PV-VITALS-V1-002/direction", + "vitals_contract_version": "PV-VITALS-V1-002" + }, + { + "band": "MOVING", + "band_semantics": "EXACT", + "dependency_group_ids": [ + "CLUTTER_FLOW_FORGE", + "FLOW_PULSE_ACTIVITY" + ], + "derived": { + "median_time_to_merge_hours_28d": 20, + "median_time_to_merge_seconds_28d": { + "denominator": 1, + "numerator": 72000 + }, + "merged_count_28d": 8, + "oldest_open_age_days": 3, + "open_count": 2 + }, + "diagnostics": [], + "evaluation_status": "AVAILABLE", + "explanation": "2 open change requests; 8 merged in 28 days; oldest open for 3 days; median time to merge 20h 0m.", + "inputs": [ + { + "freshness": "FRESH", + "observation_id": "forge.change_requests.open_count", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "forge.change_requests.merged_count_28d", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "forge.change_requests.oldest_open_age_days", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "forge.change_requests.median_time_to_merge_seconds_28d", + "status": "AVAILABLE" + } + ], + "policy_version": "devostasis.policy.v1", + "possible_bands": null, + "rule_id": "flow.bands.v1", + "shared_signal_groups": [ + "CHANGE_REQUEST_INVENTORY", + "CHANGE_REQUEST_ACTIVITY" + ], + "vital_id": "flow", + "vital_version": "PV-VITALS-V1-002/flow", + "vitals_contract_version": "PV-VITALS-V1-002" + }, + { + "band": "FLAKY", + "band_semantics": "EXACT", + "dependency_group_ids": [ + "INTEGRITY_ONLY" + ], + "derived": { + "current_revision": { + "committed_at": "2026-08-29T09:00:00Z", + "current_verdict": "VERIFY_PASS", + "history_state": "PASS_ONLY_OBSERVED", + "revision": "0000000000000000000000000000000000000013" + }, + "decisive_count_14d": 8, + "failed_count_14d": 1, + "failure_ratio_14d": { + "den": 8, + "num": 1 + }, + "history_provenance": { + "ATTEMPT_LEVEL": 8 + }, + "history_rule": "PV-HIST-002", + "revision_history": { + "lineage": "devostasis.ci-history.v2", + "records": [ + { + "committed_at": "2026-08-22T09:00:00Z", + "historical_contribution": "VERIFY_PASS", + "history_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "parent_groups": [ + { + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "parent_ids": [ + "github_actions:workflow_run:900" + ] + } + ], + "revision": "000000000000000000000000000000000000000c" + }, + { + "committed_at": "2026-08-23T09:00:00Z", + "historical_contribution": "VERIFY_PASS", + "history_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "parent_groups": [ + { + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "parent_ids": [ + "github_actions:workflow_run:901" + ] + } + ], + "revision": "000000000000000000000000000000000000000d" + }, + { + "committed_at": "2026-08-24T09:00:00Z", + "historical_contribution": "VERIFY_PASS", + "history_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "parent_groups": [ + { + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "parent_ids": [ + "github_actions:workflow_run:902" + ] + } + ], + "revision": "000000000000000000000000000000000000000e" + }, + { + "committed_at": "2026-08-25T09:00:00Z", + "historical_contribution": "VERIFY_FAIL", + "history_complete": true, + "history_state": "FAILURE_OBSERVED", + "parent_groups": [ + { + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_FAIL" + }, + { + "attempt": 2, + "state": "VERIFY_PASS" + } + ], + "kind": "github_actions_workflow_run", + "latest_attempt": 2, + "parent_ids": [ + "github_actions:workflow_run:903" + ] + } + ], + "revision": "000000000000000000000000000000000000000f" + }, + { + "committed_at": "2026-08-26T09:00:00Z", + "historical_contribution": "VERIFY_PASS", + "history_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "parent_groups": [ + { + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "parent_ids": [ + "github_actions:workflow_run:904" + ] + } + ], + "revision": "0000000000000000000000000000000000000010" + }, + { + "committed_at": "2026-08-27T09:00:00Z", + "historical_contribution": "VERIFY_PASS", + "history_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "parent_groups": [ + { + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "parent_ids": [ + "github_actions:workflow_run:905" + ] + } + ], + "revision": "0000000000000000000000000000000000000011" + }, + { + "committed_at": "2026-08-28T09:00:00Z", + "historical_contribution": "VERIFY_PASS", + "history_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "parent_groups": [ + { + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "parent_ids": [ + "github_actions:workflow_run:906" + ] + } + ], + "revision": "0000000000000000000000000000000000000012" + }, + { + "committed_at": "2026-08-29T09:00:00Z", + "historical_contribution": "VERIFY_PASS", + "history_complete": true, + "history_state": "PASS_ONLY_OBSERVED", + "parent_groups": [ + { + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "parent_ids": [ + "github_actions:workflow_run:907" + ] + } + ], + "revision": "0000000000000000000000000000000000000013" + } + ], + "source": { + "reason": "NO_PREVIOUS_BUNDLE", + "status": "BASELINE" + } + }, + "revisions_in_window": 8, + "revisions_with_history": 8, + "revisions_with_verification": 8, + "sample_strength": "ESTABLISHED", + "unknown_history_count_14d": 0 + }, + "diagnostics": [], + "evaluation_status": "AVAILABLE", + "explanation": "1 of 8 decisive revisions failed verification in 14 days; latest decisive verdict is VERIFY_PASS.", + "inputs": [ + { + "freshness": "FRESH", + "observation_id": "ci.configured", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "ci.revision_verdicts_14d", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "ci.revision_history_carried", + "status": "UNAVAILABLE" + } + ], + "policy_version": "devostasis.policy.v1", + "possible_bands": null, + "rule_id": "integrity.bands.v1+ci-unit-004+hist-002", + "shared_signal_groups": [ + "CI_VERIFICATION" + ], + "vital_id": "integrity", + "vital_version": "PV-VITALS-V1-002/integrity", + "vitals_contract_version": "PV-VITALS-V1-002" + }, + { + "band": "PRESENT", + "band_semantics": "EXACT", + "dependency_group_ids": [ + "DEBT_CLUTTER_MAINTENANCE" + ], + "derived": { + "capability": "CONFIGURED", + "closed_count_28d": 1, + "mapping": { + "labels": [ + "type:refactor" + ], + "mapping_version": "example-1", + "source": "labels" + }, + "open_count": 1, + "open_stale_count_30d": 0 + }, + "diagnostics": [], + "evaluation_status": "AVAILABLE", + "explanation": "1 open registered debt items under mapping version example-1.", + "inputs": [ + { + "freshness": "FRESH", + "observation_id": "debt.registry.capability", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "debt.items.open_count", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "debt.items.open_stale_count_30d", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "debt.items.closed_count_28d", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "debt.mapping", + "status": "AVAILABLE" + } + ], + "policy_version": "devostasis.policy.v1", + "possible_bands": null, + "rule_id": "debt.bands.v2", + "shared_signal_groups": [ + "EXPLICIT_DEBT_REGISTER", + "FORGE_INVENTORY" + ], + "vital_id": "debt", + "vital_version": "PV-VITALS-V1-002/debt", + "vitals_contract_version": "PV-VITALS-V1-002" + }, + { + "band": "SURGING", + "band_semantics": "EXACT", + "dependency_group_ids": [ + "FLOW_PULSE_ACTIVITY", + "DIRECTION_PULSE_ACTIVITY" + ], + "derived": { + "activity_events_28d": 32, + "channel_count": 3, + "channels_observed": { + "commits": 19, + "forge.change_requests.updated_count_28d": 10, + "forge.issues.updated_count_28d": 3 + }, + "commit_active_days_28d": 19, + "commits_28d": 19 + }, + "diagnostics": [], + "evaluation_status": "AVAILABLE", + "explanation": "19 default-branch commits on 19 active days and 32 activity events across 3 channels in 28 days.", + "inputs": [ + { + "freshness": "FRESH", + "observation_id": "git.default_branch.commits.count_28d", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "git.default_branch.commit_active_days_28d", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "forge.change_requests.updated_count_28d", + "status": "AVAILABLE" + }, + { + "freshness": "FRESH", + "observation_id": "forge.issues.updated_count_28d", + "status": "AVAILABLE" + } + ], + "policy_version": "devostasis.policy.v1", + "possible_bands": null, + "rule_id": "pulse.bands.v1", + "shared_signal_groups": [ + "DEFAULT_BRANCH_ACTIVITY", + "CHANGE_REQUEST_ACTIVITY", + "ISSUE_ACTIVITY" + ], + "vital_id": "pulse", + "vital_version": "PV-VITALS-V1-002/pulse", + "vitals_contract_version": "PV-VITALS-V1-002" + } + ], + "vitals_contract_version": "PV-VITALS-V1-002" +} diff --git a/examples/legacy/work-v1/evidence.json b/examples/legacy/work-v1/evidence.json new file mode 100644 index 0000000..8ddf48b --- /dev/null +++ b/examples/legacy/work-v1/evidence.json @@ -0,0 +1 @@ +{"contract":"devostasis.work.v1","kind":"evidence","sources":[{"observed_at":"2026-10-02T12:00:00Z","producer":"pytest","profile":"junit.v1","reason":null,"report":{"base64":"PHRlc3RzdWl0ZT48dGVzdGNhc2UgZmlsZT0ic3JjL2FwcC5weSIgbGluZT0iMTIiIG5hbWU9InRlc3RfaW52YWxpZCI+PGZhaWx1cmUvPjwvdGVzdGNhc2U+PC90ZXN0c3VpdGU+","digest":"sha256:fbc111bcc1e072a64b0ad705a5044dfc413da6abf39c8c5e4b93d9ae04f6fb71"},"status":"COMPLETE","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"version":"8-profile-1"}]} \ No newline at end of file diff --git a/examples/legacy/work-v1/inventory.json b/examples/legacy/work-v1/inventory.json new file mode 100644 index 0000000..e591d68 --- /dev/null +++ b/examples/legacy/work-v1/inventory.json @@ -0,0 +1 @@ +{"changes":{"items":[{"author":"worker","base":"cccccccccccccccccccccccccccccccccccccccc","checks":{"items":[{"at":"2026-10-02T12:00:00Z","id":"c10","name":"tests","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"PASS"}],"reasons":[],"status":"COMPLETE"},"conflict":false,"draft":false,"files":{"items":["src/app.py"],"reasons":[],"status":"COMPLETE"},"head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","id":"10","merge_train":false,"owners":[],"reviewers":[],"reviews":{"items":[{"actor":"reviewer","at":"2026-10-02T12:00:00Z","id":"r10","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"APPROVED"}],"reasons":[],"status":"COMPLETE"},"state":"OPEN","threads":{"items":[],"reasons":[],"status":"COMPLETE"},"title":"Add the feature","updated_at":"2026-10-02T12:00:00Z"},{"author":"other","base":"cccccccccccccccccccccccccccccccccccccccc","checks":{"items":[{"at":"2026-10-02T12:00:00Z","id":"c10","name":"tests","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"PASS"}],"reasons":[],"status":"COMPLETE"},"conflict":false,"draft":false,"files":{"items":["src/app.py"],"reasons":[],"status":"COMPLETE"},"head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","id":"11","merge_train":false,"owners":[],"reviewers":[],"reviews":{"items":[],"reasons":[],"status":"COMPLETE"},"state":"OPEN","threads":{"items":[],"reasons":[],"status":"COMPLETE"},"title":"Review the parser","updated_at":"2026-10-02T12:00:00Z"}],"reasons":[],"status":"COMPLETE"},"contract":"devostasis.work.v1","issues":{"items":[{"id":"20","owners":["worker"],"state":"OPEN","title":"Add validation","updated_at":"2026-10-02T12:00:00Z"}],"reasons":[],"status":"COMPLETE"},"kind":"inventory","observed_at":"2026-10-02T12:00:00Z","receipts":[],"selection":{"changes":"OPEN_AND_REGISTERED","issues":["20"]},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}} \ No newline at end of file diff --git a/examples/legacy/work-v1/manifest.json b/examples/legacy/work-v1/manifest.json new file mode 100644 index 0000000..7cdd536 --- /dev/null +++ b/examples/legacy/work-v1/manifest.json @@ -0,0 +1 @@ +{"bundle_id":"sha256:5333c42f402d006db1514d8b9269edf36bf0eea03892dd52a3771ae0802bf5bd","contract":"devostasis.work.v1","engine":"devostasis.work-engine.v1","kind":"bundle","members":{"evidence.json":"sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672","inventory.json":"sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","policy.json":"sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2","report.md":"sha256:bb2812581ce80544219d0eb6faa77fa858b30203c81a6be97d0bbeeb8fd509eb","scope.json":"sha256:11d17f4baf7bf952a74b68922e30d9d1905e1cadf0a96bd872699eebcdba4c95"},"vitals_anchor":null} \ No newline at end of file diff --git a/examples/legacy/work-v1/policy.json b/examples/legacy/work-v1/policy.json new file mode 100644 index 0000000..cf9198b --- /dev/null +++ b/examples/legacy/work-v1/policy.json @@ -0,0 +1 @@ +{"actor":"worker","allow_merge_train":false,"contract":"devostasis.work.v1","criteria":[{"acceptance":["Reject an invalid input and accept a valid input."],"dependencies":[],"done":false,"id":"validation","implementations":[],"issue":"20","paths":["src/app.py"],"symbols":["validate"]}],"dispositions":[],"kind":"policy","minimum_approvals":1,"priorities":{"issue:20":{"rank":0,"rationale":"Explicit customer acceptance."}},"queue_order":["review","finish_merge","implement_issue","research","analyze_code"],"read_budget":{"max_bytes":200000,"max_files":20},"requests":[{"acceptance":["Record a bounded reproducer and a rule verdict."],"dependencies":[],"id":"parser-question","owner":null,"paths":["src/parser.py"],"question":"Which parser rule explains the recorded contradiction?","queue":"research","symbols":["parse"]}],"required_checks":["tests"],"roles":["all"],"ttl_seconds":3600,"version":"widget-1"} \ No newline at end of file diff --git a/examples/legacy/work-v1/report.md b/examples/legacy/work-v1/report.md new file mode 100644 index 0000000..91e65db --- /dev/null +++ b/examples/legacy/work-v1/report.md @@ -0,0 +1,17 @@ +# Devostasis work scope + +Observed: 2026-10-02T12:00:00Z · expires: 2026-10-02T13:00:00Z + +Read-only proposal. Recheck source state and consumer authorization before execution. + +| Queue | Eligibility | Source | Action | Reasons | +| --- | --- | --- | --- | --- | +| implement_issue | READY | issue:20 | implement_issue | | +| finish_merge | READY | change:10 | merge | | +| review | READY | change:11 | review | | +| research | READY | request:parser-question | research | | +| analyze_code | READY | sha256:72b814d58402f4b5e53fab7af673e1d2a735d53232ea870adf97e0c8b5e81ef5 | analyze_code | | +| finish_merge | BLOCKED | change:11 | rework | FOREIGN_IMPLEMENTATION_OWNER | + +Coverage: changes=COMPLETE, issues=COMPLETE +Excluded: 1; evidence gaps: 0. diff --git a/examples/legacy/work-v1/scope.json b/examples/legacy/work-v1/scope.json new file mode 100644 index 0000000..065f02a --- /dev/null +++ b/examples/legacy/work-v1/scope.json @@ -0,0 +1 @@ +{"authority":"Read-only proposal. Recheck source state and consumer authorization before execution.","contract":"devostasis.work.v1","coverage":{"changes":"COMPLETE","issues":"COMPLETE"},"excluded":[{"reason":"AUTHOR_CANNOT_INDEPENDENTLY_REVIEW","source":"change:10"}],"expires_at":"2026-10-02T13:00:00Z","gaps":[],"inputs":{"evidence":"sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672","inventory":"sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","policy":"sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2"},"items":[{"acceptance":["Reject an invalid input and accept a valid input."],"action":"implement_issue","continuation":true,"criterion":"validation","dependencies":[],"eligibility":"READY","evidence":["sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2","sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672"],"executor":"worker","fingerprint":"sha256:eb1436cb7b598c5e31784df760fe4aaf05be60b89efb4b1fb62243f0eee1936c","id":"sha256:939096a134dafc3bad31f2fd780c7a8097923a4730d842d5874d5f5a8f475870","implementation_owner":"worker","merge_gate":null,"priority":{"rank":0,"rationale":"Explicit customer acceptance."},"queue":"implement_issue","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/app.py"],"symbols":["validate"],"total_paths":1},"reasons":[],"revision":{"content":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","issue":"sha256:ccfa6af76afa4585b4c323fe0a14e8ff12248a466e9b046bd68488116de59f99"},"source":"issue:20","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"title":"Add validation"},{"acceptance":["Resolve the listed blockers and recheck this exact head before any authorized merge."],"action":"merge","continuation":true,"criterion":null,"dependencies":[],"eligibility":"READY","evidence":["sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2","sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672"],"executor":"worker","fingerprint":"sha256:2bc320846e006cbae7acd12fa3de5401bf35bc4937b8c3a43100b38f6f97c77e","id":"sha256:831f8d5b289ae3912bcc93aa71139fa9df7392fd6d6b817c3ad8a06c7be6ed2f","implementation_owner":"worker","merge_gate":{"reasons":[],"status":"READY"},"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"finish_merge","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/app.py"],"symbols":[],"total_paths":1},"reasons":[],"revision":{"base":"cccccccccccccccccccccccccccccccccccccccc","head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"source":"change:10","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"title":"Add the feature"},{"acceptance":["Record an independent verdict on this exact head and scope."],"action":"review","continuation":false,"criterion":null,"dependencies":[],"eligibility":"READY","evidence":["sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2","sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672"],"executor":"worker","fingerprint":"sha256:373572fac32b17d72a203a57c54ca74a5158ea76a02d3e4fe60aa509240b1d32","id":"sha256:cea5d7476fdb1291a2f5ef31bc5e706778eb6efb538b85cc5b5d963d19dd9a29","implementation_owner":null,"merge_gate":null,"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"review","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/app.py"],"symbols":[],"total_paths":1},"reasons":[],"revision":{"base":"cccccccccccccccccccccccccccccccccccccccc","head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"source":"change:11","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"title":"Review the parser"},{"acceptance":["Record a bounded reproducer and a rule verdict."],"action":"research","continuation":false,"criterion":"parser-question","dependencies":[],"eligibility":"READY","evidence":["sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2","sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672"],"executor":"worker","fingerprint":"sha256:3401ae47484af8156fcec4bef8a8d31efc5725309ddebf01c727bd51e30c40b1","id":"sha256:90e01a30cfbd76653abdda9110e68cec8bb281e32581d4db62f27731a7a6f1a4","implementation_owner":null,"merge_gate":null,"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"research","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/parser.py"],"symbols":["parse"],"total_paths":1},"reasons":[],"revision":{"content":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"source":"request:parser-question","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"title":"Which parser rule explains the recorded contradiction?"},{"acceptance":["Reproduce this exact test at the recorded revision and verify its expected behavior."],"action":"analyze_code","continuation":false,"criterion":"test-failure","dependencies":[],"eligibility":"READY","evidence":["sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2","sha256:fbc111bcc1e072a64b0ad705a5044dfc413da6abf39c8c5e4b93d9ae04f6fb71","sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672"],"executor":"worker","fingerprint":"sha256:4ede0045b25ddbebd6ba0bafdf053513bdbecd120ffd5a508f6f202ba219bc92","id":"sha256:1bbbb2fff8d71f3ba3b60dd8a930d7de40d0bc7133e74f8dd7b78535e966ea63","implementation_owner":null,"merge_gate":null,"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"analyze_code","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/app.py"],"symbols":["test_invalid"],"total_paths":1},"reasons":[],"revision":{"content":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"source":"sha256:72b814d58402f4b5e53fab7af673e1d2a735d53232ea870adf97e0c8b5e81ef5","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"title":"A test failure, error or explicit flaky rerun was reported."},{"acceptance":["Resolve the listed blockers and recheck this exact head before any authorized merge."],"action":"rework","continuation":false,"criterion":null,"dependencies":[],"eligibility":"BLOCKED","evidence":["sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2","sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672"],"executor":"worker","fingerprint":"sha256:a698103ade12c1fa81dcf95b04e33f45c334170370736d1cf74f6cc181edc070","id":"sha256:747302f672eb5452b56f574af8513024297d77544b8499ec26d5aa2440cda4cd","implementation_owner":"other","merge_gate":{"reasons":["EXACT_HEAD_APPROVAL_REQUIRED"],"status":"BLOCKED"},"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"finish_merge","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/app.py"],"symbols":[],"total_paths":1},"reasons":["FOREIGN_IMPLEMENTATION_OWNER"],"revision":{"base":"cccccccccccccccccccccccccccccccccccccccc","head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"source":"change:11","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"title":"Review the parser"}],"kind":"scope","observed_at":"2026-10-02T12:00:00Z","planned_at":"2026-10-02T12:00:00Z","policy_version":"widget-1","queues":{"analyze_code":["sha256:1bbbb2fff8d71f3ba3b60dd8a930d7de40d0bc7133e74f8dd7b78535e966ea63"],"finish_merge":["sha256:831f8d5b289ae3912bcc93aa71139fa9df7392fd6d6b817c3ad8a06c7be6ed2f","sha256:747302f672eb5452b56f574af8513024297d77544b8499ec26d5aa2440cda4cd"],"implement_issue":["sha256:939096a134dafc3bad31f2fd780c7a8097923a4730d842d5874d5f5a8f475870"],"research":["sha256:90e01a30cfbd76653abdda9110e68cec8bb281e32581d4db62f27731a7a6f1a4"],"review":["sha256:cea5d7476fdb1291a2f5ef31bc5e706778eb6efb538b85cc5b5d963d19dd9a29"]},"selection":{"changes":"OPEN_AND_REGISTERED","issues":["20"]},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}} \ No newline at end of file diff --git a/examples/observations.json b/examples/observations.json index 37abb75..a515f56 100644 --- a/examples/observations.json +++ b/examples/observations.json @@ -834,14 +834,54 @@ } ], "observed_at": "2026-09-05T12:00:00Z", - "receipt": { + "receipt_identity": { "capability_notes": [ "CHECKS_SURFACE_NOT_COLLECTED", "CI_SURFACE:GITHUB_ACTIONS_ONLY" ], "collector_version": "devostasis.github.v1", "config_hash": "example", - "ended_at": "2026-09-05T12:00:00Z", + "contract": "PV-RECEIPT-IDENTITY-003", + "coverage": { + "ci.revision_verdicts_14d": { + "attempts_complete": true, + "outcome_map_version": "devostasis.ci-outcomes.github.v1", + "runs_complete": true, + "surface": "github_actions", + "window_end": "2026-09-05T12:00:00Z", + "window_start": "2026-08-22T12:00:00Z" + }, + "forge.change_requests.inventory": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + }, + "forge.issues.inventory": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + }, + "forge.releases.inventory": { + "complete": true, + "limit": 30, + "recent_only": true + }, + "git.default_branch.commits_28d": { + "branch": "master", + "complete": true, + "window_end": "2026-09-05T12:00:00Z", + "window_start": "2026-08-08T12:00:00Z" + }, + "git.nondefault_branches.inventory": { + "complete": true, + "head_lookups": 2, + "heads_resolved": true + }, + "planning.explicit_targets.inventory": { + "complete": true, + "source": "milestones" + } + }, "per_key": { "ci.configured": { "freshness": "FRESH", @@ -902,9 +942,7 @@ "git.nondefault_branches.inventory", "planning.explicit_targets.inventory" ], - "run_id": "run-20260905T120000Z-acme-widget", - "schema": "devostasis.receipt.v2", - "started_at": "2026-09-05T12:00:00Z", + "schema": "devostasis.receipt-identity.v1", "target": { "default_branch": "master", "display_locator": "acme/widget", @@ -916,7 +954,7 @@ "visibility": "public" } }, - "schema": "devostasis.observations.v1", + "schema": "devostasis.observations.v2", "subject": { "default_branch": "master", "display_locator": "acme/widget", diff --git a/examples/sample-bundle/manifest.json b/examples/sample-bundle/manifest.json index c7e09f9..1ec79cc 100644 --- a/examples/sample-bundle/manifest.json +++ b/examples/sample-bundle/manifest.json @@ -5,9 +5,9 @@ "provider": "github" } ], - "artifact_contract_version": "devostasis.bundle.v2", - "bundle_id": "422f6c269728988da2808817b5e72db3f3df97a330fb91c52f3f53e6ee76554c", - "bundle_identity_contract": "PV-BUNDLE-ID-002", + "artifact_contract_version": "devostasis.bundle.v3", + "bundle_id": "868e4cb6c321a66e14bf6b1dc917cc51ba66ee9535edfb4d5e36ea9045631cd3", + "bundle_identity_contract": "PV-BUNDLE-ID-003", "canonical_member_profile": { "activity_json": "ENABLED", "demand_json": "REQUIRED", @@ -28,8 +28,8 @@ "gauge_contract": "devostasis.gauge.v1", "identity_preimage": { "activity_digest": "sha256:d80fa63f29071d59425dad060211db5fbf6d165248c23b18169b06c37a1a0595", - "artifact_contract_version": "devostasis.bundle.v2", - "bundle_identity_contract": "PV-BUNDLE-ID-002", + "artifact_contract_version": "devostasis.bundle.v3", + "bundle_identity_contract": "PV-BUNDLE-ID-003", "canonical_serialization_version": "devostasis.canon.v1", "ci_unit_contract_version": "PV-CI-UNIT-004", "comparison_status": "BASELINE", @@ -41,8 +41,10 @@ "effective_config_digest": "sha256:cc472c9ad93f6721ecc9a3fc14f3067ccc85af33c2acceb4489982a44eb370ac", "gauge_contract": "devostasis.gauge.v1", "gauges_digest": "sha256:f396534cc1b29a280d8680d2ce89826dfc9ef99f7cd84262cb6e80009dbad2be", + "manifest_schema": "devostasis.manifest.v2", "observation_contract_version": "RAW-OBS-V0", - "observations_digest": "sha256:78603834c2e4cb90798cc144488c5b9e8d99407908269572a622ed13f1e58b33", + "observations_digest": "sha256:89cec71d9b93d5ec2b691af8c7fe9aef590b566fce9edb9d284757bbcb6e10a4", + "observations_schema": "devostasis.observations.v2", "observed_at": "2026-09-05T12:00:00Z", "policy_version": "devostasis.policy.v1", "previous_bundle_id": null, @@ -53,22 +55,26 @@ "immutable_project_id": "123456", "provider": "github" }, - "renderer_version": "devostasis.render.v4", - "snapshot_digest": "sha256:d0274768919e75f1c6f76b6720e7612282229f7af11f050d76cc8a29bf4b85b9", - "source_receipts_digest": "sha256:fda4a9bc9f4f74739ecd59c85eed55a83e95a7085ae22fa7d82d9c98f36ecde3", + "receipt_identity_contract": "PV-RECEIPT-IDENTITY-003", + "receipt_identity_schema": "devostasis.receipt-identity.v1", + "renderer_version": "devostasis.render.v5", + "snapshot_digest": "sha256:992eb83302dccb7c10ddfa99b8c7e698cc435faa333b112e47d0073e33fe1ff7", + "source_receipts_digest": "sha256:e452c2b8176c4f8e05e875d817fb38624a69c05f2480fef6133dfa99c5107d05", "vitals_contract_version": "PV-VITALS-V1-002" }, + "manifest_schema": "devostasis.manifest.v2", "members": { "activity.json": "sha256:d80fa63f29071d59425dad060211db5fbf6d165248c23b18169b06c37a1a0595", "delta.json": "sha256:a36c8ad814fc8ae9c9e2d0b31f7764fc7a5ca959a39017922a389994029cf8f8", "demand.json": "sha256:38f9170d967c28516dcaf4ca34d77815c0b5589701c076fa1ee176a4f64f0d94", "effective-config.json": "sha256:cc472c9ad93f6721ecc9a3fc14f3067ccc85af33c2acceb4489982a44eb370ac", "gauges.json": "sha256:f396534cc1b29a280d8680d2ce89826dfc9ef99f7cd84262cb6e80009dbad2be", - "observations.json": "sha256:78603834c2e4cb90798cc144488c5b9e8d99407908269572a622ed13f1e58b33", - "report.md": "sha256:4b7c14593a029b47956d7343c839d221b9f4146a960c1a9eba3d6894cf9f2598", - "snapshot.json": "sha256:d0274768919e75f1c6f76b6720e7612282229f7af11f050d76cc8a29bf4b85b9" + "observations.json": "sha256:89cec71d9b93d5ec2b691af8c7fe9aef590b566fce9edb9d284757bbcb6e10a4", + "report.md": "sha256:4eac81799ae0f0d67d1548562cb92113653dcad87973100513dcc865c879be94", + "snapshot.json": "sha256:992eb83302dccb7c10ddfa99b8c7e698cc435faa333b112e47d0073e33fe1ff7" }, "observation_contract_version": "RAW-OBS-V0", + "observations_schema": "devostasis.observations.v2", "observed_at": "2026-09-05T12:00:00Z", "policy_version": "devostasis.policy.v1", "previous_bundle_id": null, @@ -80,14 +86,54 @@ "provider": "github" }, "project_key": "github.com/acme/widget", - "receipt": { + "receipt_identity": { "capability_notes": [ "CHECKS_SURFACE_NOT_COLLECTED", "CI_SURFACE:GITHUB_ACTIONS_ONLY" ], "collector_version": "devostasis.github.v1", "config_hash": "example", - "ended_at": "2026-09-05T12:00:00Z", + "contract": "PV-RECEIPT-IDENTITY-003", + "coverage": { + "ci.revision_verdicts_14d": { + "attempts_complete": true, + "outcome_map_version": "devostasis.ci-outcomes.github.v1", + "runs_complete": true, + "surface": "github_actions", + "window_end": "2026-09-05T12:00:00Z", + "window_start": "2026-08-22T12:00:00Z" + }, + "forge.change_requests.inventory": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + }, + "forge.issues.inventory": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + }, + "forge.releases.inventory": { + "complete": true, + "limit": 30, + "recent_only": true + }, + "git.default_branch.commits_28d": { + "branch": "master", + "complete": true, + "window_end": "2026-09-05T12:00:00Z", + "window_start": "2026-08-08T12:00:00Z" + }, + "git.nondefault_branches.inventory": { + "complete": true, + "head_lookups": 2, + "heads_resolved": true + }, + "planning.explicit_targets.inventory": { + "complete": true, + "source": "milestones" + } + }, "per_key": { "ci.configured": { "freshness": "FRESH", @@ -148,9 +194,7 @@ "git.nondefault_branches.inventory", "planning.explicit_targets.inventory" ], - "run_id": "run-20260905T120000Z-acme-widget", - "schema": "devostasis.receipt.v2", - "started_at": "2026-09-05T12:00:00Z", + "schema": "devostasis.receipt-identity.v1", "target": { "default_branch": "master", "display_locator": "acme/widget", @@ -162,15 +206,10 @@ "visibility": "public" } }, - "renderer_version": "devostasis.render.v4", - "run_meta": { - "example": true, - "tool": { - "name": "devostasis", - "version": "0.2.0" - } - }, - "schema": "devostasis.manifest.v1", + "receipt_identity_contract": "PV-RECEIPT-IDENTITY-003", + "receipt_identity_schema": "devostasis.receipt-identity.v1", + "renderer_version": "devostasis.render.v5", + "schema": "devostasis.manifest.v2", "semantic_config": { "debt_mapping": { "labels": [ diff --git a/examples/sample-bundle/observations.json b/examples/sample-bundle/observations.json index 415e73e..f1cd102 100644 --- a/examples/sample-bundle/observations.json +++ b/examples/sample-bundle/observations.json @@ -1699,14 +1699,54 @@ } ], "observed_at": "2026-09-05T12:00:00Z", - "receipt": { + "receipt_identity": { "capability_notes": [ "CHECKS_SURFACE_NOT_COLLECTED", "CI_SURFACE:GITHUB_ACTIONS_ONLY" ], "collector_version": "devostasis.github.v1", "config_hash": "example", - "ended_at": "2026-09-05T12:00:00Z", + "contract": "PV-RECEIPT-IDENTITY-003", + "coverage": { + "ci.revision_verdicts_14d": { + "attempts_complete": true, + "outcome_map_version": "devostasis.ci-outcomes.github.v1", + "runs_complete": true, + "surface": "github_actions", + "window_end": "2026-09-05T12:00:00Z", + "window_start": "2026-08-22T12:00:00Z" + }, + "forge.change_requests.inventory": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + }, + "forge.issues.inventory": { + "open_complete": true, + "window_complete": true, + "window_start": "2026-08-08T12:00:00Z" + }, + "forge.releases.inventory": { + "complete": true, + "limit": 30, + "recent_only": true + }, + "git.default_branch.commits_28d": { + "branch": "master", + "complete": true, + "window_end": "2026-09-05T12:00:00Z", + "window_start": "2026-08-08T12:00:00Z" + }, + "git.nondefault_branches.inventory": { + "complete": true, + "head_lookups": 2, + "heads_resolved": true + }, + "planning.explicit_targets.inventory": { + "complete": true, + "source": "milestones" + } + }, "per_key": { "ci.configured": { "freshness": "FRESH", @@ -1767,9 +1807,7 @@ "git.nondefault_branches.inventory", "planning.explicit_targets.inventory" ], - "run_id": "run-20260905T120000Z-acme-widget", - "schema": "devostasis.receipt.v2", - "started_at": "2026-09-05T12:00:00Z", + "schema": "devostasis.receipt-identity.v1", "target": { "default_branch": "master", "display_locator": "acme/widget", @@ -1781,7 +1819,7 @@ "visibility": "public" } }, - "schema": "devostasis.observations.v1", + "schema": "devostasis.observations.v2", "subject": { "default_branch": "master", "display_locator": "acme/widget", diff --git a/examples/sample-bundle/report.md b/examples/sample-bundle/report.md index 284a3fd..774439c 100644 --- a/examples/sample-bundle/report.md +++ b/examples/sample-bundle/report.md @@ -2,7 +2,7 @@ - Observed at: 2026-09-05T12:00:00Z - Comparison: BASELINE -- Bundle: `422f6c269728988da2808817b5e72db3f3df97a330fb91c52f3f53e6ee76554c` +- Bundle: `868e4cb6c321a66e14bf6b1dc917cc51ba66ee9535edfb4d5e36ea9045631cd3` - Contracts: vitals PV-VITALS-V1-002, observations RAW-OBS-V0, policy devostasis.policy.v1 ```text @@ -217,8 +217,8 @@ Releases: ## Provenance -- Artifact contract: devostasis.bundle.v2; bundle identity: PV-BUNDLE-ID-002; renderer: devostasis.render.v4; gauges: devostasis.gauge.v1; demand: devostasis.demand.v2 +- Artifact contract: devostasis.bundle.v3; bundle identity: PV-BUNDLE-ID-003; renderer: devostasis.render.v5; gauges: devostasis.gauge.v1; demand: devostasis.demand.v2 - Effective config digest: `sha256:cc472c9ad93f6721ecc9a3fc14f3067ccc85af33c2acceb4489982a44eb370ac` (config version `example-1`) - Adapters: github devostasis.github.v1 -- Observations digest: `sha256:78603834c2e4cb90798cc144488c5b9e8d99407908269572a622ed13f1e58b33` +- Observations digest: `sha256:89cec71d9b93d5ec2b691af8c7fe9aef590b566fce9edb9d284757bbcb6e10a4` - Generated deterministically from the machine bundle without any language model. diff --git a/examples/sample-bundle/snapshot.json b/examples/sample-bundle/snapshot.json index 3bc19f3..0aba75d 100644 --- a/examples/sample-bundle/snapshot.json +++ b/examples/sample-bundle/snapshot.json @@ -1,6 +1,6 @@ { "observation_contract_version": "RAW-OBS-V0", - "observations_digest": "sha256:78603834c2e4cb90798cc144488c5b9e8d99407908269572a622ed13f1e58b33", + "observations_digest": "sha256:89cec71d9b93d5ec2b691af8c7fe9aef590b566fce9edb9d284757bbcb6e10a4", "observed_at": "2026-09-05T12:00:00Z", "policy_version": "devostasis.policy.v1", "schema": "devostasis.snapshot.v1", diff --git a/examples/work/bundle/evidence.json b/examples/work/bundle/evidence.json index 8ddf48b..0b95fe8 100644 --- a/examples/work/bundle/evidence.json +++ b/examples/work/bundle/evidence.json @@ -1 +1 @@ -{"contract":"devostasis.work.v1","kind":"evidence","sources":[{"observed_at":"2026-10-02T12:00:00Z","producer":"pytest","profile":"junit.v1","reason":null,"report":{"base64":"PHRlc3RzdWl0ZT48dGVzdGNhc2UgZmlsZT0ic3JjL2FwcC5weSIgbGluZT0iMTIiIG5hbWU9InRlc3RfaW52YWxpZCI+PGZhaWx1cmUvPjwvdGVzdGNhc2U+PC90ZXN0c3VpdGU+","digest":"sha256:fbc111bcc1e072a64b0ad705a5044dfc413da6abf39c8c5e4b93d9ae04f6fb71"},"status":"COMPLETE","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"version":"8-profile-1"}]} \ No newline at end of file +{"contract":"devostasis.work.v2","kind":"evidence","sources":[{"observed_at":"2026-10-02T12:00:00Z","producer":"pytest","profile":"junit.v1","reason":null,"report":{"base64":"PHRlc3RzdWl0ZT48dGVzdGNhc2UgZmlsZT0ic3JjL2FwcC5weSIgbGluZT0iMTIiIG5hbWU9InRlc3RfaW52YWxpZCI+PGZhaWx1cmUvPjwvdGVzdGNhc2U+PC90ZXN0c3VpdGU+","digest":"sha256:fbc111bcc1e072a64b0ad705a5044dfc413da6abf39c8c5e4b93d9ae04f6fb71"},"status":"COMPLETE","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}},"version":"8-profile-1"}]} \ No newline at end of file diff --git a/examples/work/bundle/inventory.json b/examples/work/bundle/inventory.json index e591d68..e2c3b2d 100644 --- a/examples/work/bundle/inventory.json +++ b/examples/work/bundle/inventory.json @@ -1 +1 @@ -{"changes":{"items":[{"author":"worker","base":"cccccccccccccccccccccccccccccccccccccccc","checks":{"items":[{"at":"2026-10-02T12:00:00Z","id":"c10","name":"tests","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"PASS"}],"reasons":[],"status":"COMPLETE"},"conflict":false,"draft":false,"files":{"items":["src/app.py"],"reasons":[],"status":"COMPLETE"},"head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","id":"10","merge_train":false,"owners":[],"reviewers":[],"reviews":{"items":[{"actor":"reviewer","at":"2026-10-02T12:00:00Z","id":"r10","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"APPROVED"}],"reasons":[],"status":"COMPLETE"},"state":"OPEN","threads":{"items":[],"reasons":[],"status":"COMPLETE"},"title":"Add the feature","updated_at":"2026-10-02T12:00:00Z"},{"author":"other","base":"cccccccccccccccccccccccccccccccccccccccc","checks":{"items":[{"at":"2026-10-02T12:00:00Z","id":"c10","name":"tests","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"PASS"}],"reasons":[],"status":"COMPLETE"},"conflict":false,"draft":false,"files":{"items":["src/app.py"],"reasons":[],"status":"COMPLETE"},"head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","id":"11","merge_train":false,"owners":[],"reviewers":[],"reviews":{"items":[],"reasons":[],"status":"COMPLETE"},"state":"OPEN","threads":{"items":[],"reasons":[],"status":"COMPLETE"},"title":"Review the parser","updated_at":"2026-10-02T12:00:00Z"}],"reasons":[],"status":"COMPLETE"},"contract":"devostasis.work.v1","issues":{"items":[{"id":"20","owners":["worker"],"state":"OPEN","title":"Add validation","updated_at":"2026-10-02T12:00:00Z"}],"reasons":[],"status":"COMPLETE"},"kind":"inventory","observed_at":"2026-10-02T12:00:00Z","receipts":[],"selection":{"changes":"OPEN_AND_REGISTERED","issues":["20"]},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}} \ No newline at end of file +{"changes":{"items":[{"author":"worker","base":"cccccccccccccccccccccccccccccccccccccccc","checks":{"items":[{"at":"2026-10-02T12:00:00Z","id":"c10","name":"tests","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"PASS"}],"reasons":[],"status":"COMPLETE"},"conflict":false,"draft":false,"files":{"items":["src/app.py"],"reasons":[],"status":"COMPLETE"},"head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","id":"10","merge_train":false,"owners":[],"reviewers":[],"reviews":{"items":[{"actor":"reviewer","at":"2026-10-02T12:00:00Z","id":"r10","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"APPROVED"}],"reasons":[],"status":"COMPLETE"},"source_binding":{"contract":"devostasis.work-source.v1","kind":"CHANGE","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"10","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}},"state":"OPEN","threads":{"items":[],"reasons":[],"status":"COMPLETE"},"title":"Add the feature","updated_at":"2026-10-02T12:00:00Z"},{"author":"other","base":"cccccccccccccccccccccccccccccccccccccccc","checks":{"items":[{"at":"2026-10-02T12:00:00Z","id":"c10","name":"tests","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"PASS"}],"reasons":[],"status":"COMPLETE"},"conflict":false,"draft":false,"files":{"items":["src/app.py"],"reasons":[],"status":"COMPLETE"},"head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","id":"11","merge_train":false,"owners":[],"reviewers":[],"reviews":{"items":[],"reasons":[],"status":"COMPLETE"},"source_binding":{"contract":"devostasis.work-source.v1","kind":"CHANGE","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"11","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}},"state":"OPEN","threads":{"items":[],"reasons":[],"status":"COMPLETE"},"title":"Review the parser","updated_at":"2026-10-02T12:00:00Z"}],"reasons":[],"status":"COMPLETE"},"contract":"devostasis.work.v2","issues":{"items":[{"id":"20","owners":["worker"],"state":"OPEN","title":"Add validation","updated_at":"2026-10-02T12:00:00Z"}],"reasons":[],"status":"COMPLETE"},"kind":"inventory","observed_at":"2026-10-02T12:00:00Z","receipts":[],"selection":{"changes":"OPEN_AND_REGISTERED","issues":["20"]},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}}} \ No newline at end of file diff --git a/examples/work/bundle/manifest.json b/examples/work/bundle/manifest.json index 7cdd536..4cb09a5 100644 --- a/examples/work/bundle/manifest.json +++ b/examples/work/bundle/manifest.json @@ -1 +1 @@ -{"bundle_id":"sha256:5333c42f402d006db1514d8b9269edf36bf0eea03892dd52a3771ae0802bf5bd","contract":"devostasis.work.v1","engine":"devostasis.work-engine.v1","kind":"bundle","members":{"evidence.json":"sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672","inventory.json":"sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","policy.json":"sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2","report.md":"sha256:bb2812581ce80544219d0eb6faa77fa858b30203c81a6be97d0bbeeb8fd509eb","scope.json":"sha256:11d17f4baf7bf952a74b68922e30d9d1905e1cadf0a96bd872699eebcdba4c95"},"vitals_anchor":null} \ No newline at end of file +{"bundle_id":"sha256:de44f77729c753402e19c16746f2eac31e42967ff1fc82a75ea17916e042fec9","contract":"devostasis.work.v2","engine":"devostasis.work-engine.v2","kind":"bundle","members":{"evidence.json":"sha256:83eb43051660be4e97b1da475c092bb6ccd7a2236d863880752bab3c26742f48","inventory.json":"sha256:e436985cf08ede8f667c429c37e21e9f433f6b24a5f4c5259e71ec22bcf5c4cf","policy.json":"sha256:271481e214a15c2b1ae112382fadd7019cb7da1d407946ecb115821df81ae8c9","report.md":"sha256:bb2812581ce80544219d0eb6faa77fa858b30203c81a6be97d0bbeeb8fd509eb","scope.json":"sha256:8a94569a7c27f2aa3457ff49773c8ad42275f73068a5dcda6fd676d2750b5b61"},"vitals_anchor":null} \ No newline at end of file diff --git a/examples/work/bundle/policy.json b/examples/work/bundle/policy.json index cf9198b..a740152 100644 --- a/examples/work/bundle/policy.json +++ b/examples/work/bundle/policy.json @@ -1 +1 @@ -{"actor":"worker","allow_merge_train":false,"contract":"devostasis.work.v1","criteria":[{"acceptance":["Reject an invalid input and accept a valid input."],"dependencies":[],"done":false,"id":"validation","implementations":[],"issue":"20","paths":["src/app.py"],"symbols":["validate"]}],"dispositions":[],"kind":"policy","minimum_approvals":1,"priorities":{"issue:20":{"rank":0,"rationale":"Explicit customer acceptance."}},"queue_order":["review","finish_merge","implement_issue","research","analyze_code"],"read_budget":{"max_bytes":200000,"max_files":20},"requests":[{"acceptance":["Record a bounded reproducer and a rule verdict."],"dependencies":[],"id":"parser-question","owner":null,"paths":["src/parser.py"],"question":"Which parser rule explains the recorded contradiction?","queue":"research","symbols":["parse"]}],"required_checks":["tests"],"roles":["all"],"ttl_seconds":3600,"version":"widget-1"} \ No newline at end of file +{"actor":"worker","allow_merge_train":false,"contract":"devostasis.work.v2","criteria":[{"acceptance":["Reject an invalid input and accept a valid input."],"dependencies":[],"done":false,"id":"validation","implementations":[],"issue":"20","paths":["src/app.py"],"symbols":["validate"]}],"dispositions":[],"kind":"policy","minimum_approvals":1,"priorities":{"issue:20":{"rank":0,"rationale":"Explicit customer acceptance."}},"queue_order":["review","finish_merge","implement_issue","research","analyze_code"],"read_budget":{"max_bytes":200000,"max_files":20},"requests":[{"acceptance":["Record a bounded reproducer and a rule verdict."],"dependencies":[],"id":"parser-question","owner":null,"paths":["src/parser.py"],"question":"Which parser rule explains the recorded contradiction?","queue":"research","symbols":["parse"]}],"required_checks":["tests"],"roles":["all"],"ttl_seconds":3600,"version":"widget-1"} \ No newline at end of file diff --git a/examples/work/bundle/scope.json b/examples/work/bundle/scope.json index 065f02a..27f9fbe 100644 --- a/examples/work/bundle/scope.json +++ b/examples/work/bundle/scope.json @@ -1 +1 @@ -{"authority":"Read-only proposal. Recheck source state and consumer authorization before execution.","contract":"devostasis.work.v1","coverage":{"changes":"COMPLETE","issues":"COMPLETE"},"excluded":[{"reason":"AUTHOR_CANNOT_INDEPENDENTLY_REVIEW","source":"change:10"}],"expires_at":"2026-10-02T13:00:00Z","gaps":[],"inputs":{"evidence":"sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672","inventory":"sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","policy":"sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2"},"items":[{"acceptance":["Reject an invalid input and accept a valid input."],"action":"implement_issue","continuation":true,"criterion":"validation","dependencies":[],"eligibility":"READY","evidence":["sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2","sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672"],"executor":"worker","fingerprint":"sha256:eb1436cb7b598c5e31784df760fe4aaf05be60b89efb4b1fb62243f0eee1936c","id":"sha256:939096a134dafc3bad31f2fd780c7a8097923a4730d842d5874d5f5a8f475870","implementation_owner":"worker","merge_gate":null,"priority":{"rank":0,"rationale":"Explicit customer acceptance."},"queue":"implement_issue","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/app.py"],"symbols":["validate"],"total_paths":1},"reasons":[],"revision":{"content":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","issue":"sha256:ccfa6af76afa4585b4c323fe0a14e8ff12248a466e9b046bd68488116de59f99"},"source":"issue:20","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"title":"Add validation"},{"acceptance":["Resolve the listed blockers and recheck this exact head before any authorized merge."],"action":"merge","continuation":true,"criterion":null,"dependencies":[],"eligibility":"READY","evidence":["sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2","sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672"],"executor":"worker","fingerprint":"sha256:2bc320846e006cbae7acd12fa3de5401bf35bc4937b8c3a43100b38f6f97c77e","id":"sha256:831f8d5b289ae3912bcc93aa71139fa9df7392fd6d6b817c3ad8a06c7be6ed2f","implementation_owner":"worker","merge_gate":{"reasons":[],"status":"READY"},"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"finish_merge","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/app.py"],"symbols":[],"total_paths":1},"reasons":[],"revision":{"base":"cccccccccccccccccccccccccccccccccccccccc","head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"source":"change:10","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"title":"Add the feature"},{"acceptance":["Record an independent verdict on this exact head and scope."],"action":"review","continuation":false,"criterion":null,"dependencies":[],"eligibility":"READY","evidence":["sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2","sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672"],"executor":"worker","fingerprint":"sha256:373572fac32b17d72a203a57c54ca74a5158ea76a02d3e4fe60aa509240b1d32","id":"sha256:cea5d7476fdb1291a2f5ef31bc5e706778eb6efb538b85cc5b5d963d19dd9a29","implementation_owner":null,"merge_gate":null,"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"review","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/app.py"],"symbols":[],"total_paths":1},"reasons":[],"revision":{"base":"cccccccccccccccccccccccccccccccccccccccc","head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"source":"change:11","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"title":"Review the parser"},{"acceptance":["Record a bounded reproducer and a rule verdict."],"action":"research","continuation":false,"criterion":"parser-question","dependencies":[],"eligibility":"READY","evidence":["sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2","sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672"],"executor":"worker","fingerprint":"sha256:3401ae47484af8156fcec4bef8a8d31efc5725309ddebf01c727bd51e30c40b1","id":"sha256:90e01a30cfbd76653abdda9110e68cec8bb281e32581d4db62f27731a7a6f1a4","implementation_owner":null,"merge_gate":null,"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"research","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/parser.py"],"symbols":["parse"],"total_paths":1},"reasons":[],"revision":{"content":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"source":"request:parser-question","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"title":"Which parser rule explains the recorded contradiction?"},{"acceptance":["Reproduce this exact test at the recorded revision and verify its expected behavior."],"action":"analyze_code","continuation":false,"criterion":"test-failure","dependencies":[],"eligibility":"READY","evidence":["sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2","sha256:fbc111bcc1e072a64b0ad705a5044dfc413da6abf39c8c5e4b93d9ae04f6fb71","sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672"],"executor":"worker","fingerprint":"sha256:4ede0045b25ddbebd6ba0bafdf053513bdbecd120ffd5a508f6f202ba219bc92","id":"sha256:1bbbb2fff8d71f3ba3b60dd8a930d7de40d0bc7133e74f8dd7b78535e966ea63","implementation_owner":null,"merge_gate":null,"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"analyze_code","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/app.py"],"symbols":["test_invalid"],"total_paths":1},"reasons":[],"revision":{"content":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"source":"sha256:72b814d58402f4b5e53fab7af673e1d2a735d53232ea870adf97e0c8b5e81ef5","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"title":"A test failure, error or explicit flaky rerun was reported."},{"acceptance":["Resolve the listed blockers and recheck this exact head before any authorized merge."],"action":"rework","continuation":false,"criterion":null,"dependencies":[],"eligibility":"BLOCKED","evidence":["sha256:90622978e394facd19c4734d5aa56405bab219f8e418af4087ab69dd77b76213","sha256:c8486582edd819cc5e640227f5dd787a2b1baa3949036b01618f5d605f6e7ab2","sha256:fffdf48a7c21758dbf8e1dc84401f4514f46f7b971140adb121716331e9b7672"],"executor":"worker","fingerprint":"sha256:a698103ade12c1fa81dcf95b04e33f45c334170370736d1cf74f6cc181edc070","id":"sha256:747302f672eb5452b56f574af8513024297d77544b8499ec26d5aa2440cda4cd","implementation_owner":"other","merge_gate":{"reasons":["EXACT_HEAD_APPROVAL_REQUIRED"],"status":"BLOCKED"},"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"finish_merge","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/app.py"],"symbols":[],"total_paths":1},"reasons":["FOREIGN_IMPLEMENTATION_OWNER"],"revision":{"base":"cccccccccccccccccccccccccccccccccccccccc","head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"source":"change:11","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"title":"Review the parser"}],"kind":"scope","observed_at":"2026-10-02T12:00:00Z","planned_at":"2026-10-02T12:00:00Z","policy_version":"widget-1","queues":{"analyze_code":["sha256:1bbbb2fff8d71f3ba3b60dd8a930d7de40d0bc7133e74f8dd7b78535e966ea63"],"finish_merge":["sha256:831f8d5b289ae3912bcc93aa71139fa9df7392fd6d6b817c3ad8a06c7be6ed2f","sha256:747302f672eb5452b56f574af8513024297d77544b8499ec26d5aa2440cda4cd"],"implement_issue":["sha256:939096a134dafc3bad31f2fd780c7a8097923a4730d842d5874d5f5a8f475870"],"research":["sha256:90e01a30cfbd76653abdda9110e68cec8bb281e32581d4db62f27731a7a6f1a4"],"review":["sha256:cea5d7476fdb1291a2f5ef31bc5e706778eb6efb538b85cc5b5d963d19dd9a29"]},"selection":{"changes":"OPEN_AND_REGISTERED","issues":["20"]},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}} \ No newline at end of file +{"authority":"Read-only proposal. Recheck source state and consumer authorization before execution.","contract":"devostasis.work.v2","coverage":{"changes":"COMPLETE","issues":"COMPLETE"},"excluded":[{"reason":"AUTHOR_CANNOT_INDEPENDENTLY_REVIEW","source":"change:10"}],"expires_at":"2026-10-02T13:00:00Z","gaps":[],"inputs":{"evidence":"sha256:83eb43051660be4e97b1da475c092bb6ccd7a2236d863880752bab3c26742f48","inventory":"sha256:e436985cf08ede8f667c429c37e21e9f433f6b24a5f4c5259e71ec22bcf5c4cf","policy":"sha256:271481e214a15c2b1ae112382fadd7019cb7da1d407946ecb115821df81ae8c9"},"items":[{"acceptance":["Reject an invalid input and accept a valid input."],"action":"implement_issue","continuation":true,"criterion":"validation","dependencies":[],"eligibility":"READY","evidence":["sha256:271481e214a15c2b1ae112382fadd7019cb7da1d407946ecb115821df81ae8c9","sha256:83eb43051660be4e97b1da475c092bb6ccd7a2236d863880752bab3c26742f48","sha256:e436985cf08ede8f667c429c37e21e9f433f6b24a5f4c5259e71ec22bcf5c4cf"],"executor":"worker","fingerprint":"sha256:dfacf36bdef80933a2ebda3909d2acc88b7790beb340e27aa21f51ae59a262bc","id":"sha256:939096a134dafc3bad31f2fd780c7a8097923a4730d842d5874d5f5a8f475870","implementation_owner":"worker","merge_gate":null,"priority":{"rank":0,"rationale":"Explicit customer acceptance."},"queue":"implement_issue","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/app.py"],"symbols":["validate"],"total_paths":1},"reasons":[],"revision":{"content":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","issue":"sha256:ccfa6af76afa4585b4c323fe0a14e8ff12248a466e9b046bd68488116de59f99"},"source":"issue:20","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}},"title":"Add validation"},{"acceptance":["Resolve the listed blockers and recheck this exact head before any authorized merge."],"action":"merge","continuation":true,"criterion":null,"dependencies":[],"eligibility":"READY","evidence":["sha256:271481e214a15c2b1ae112382fadd7019cb7da1d407946ecb115821df81ae8c9","sha256:83eb43051660be4e97b1da475c092bb6ccd7a2236d863880752bab3c26742f48","sha256:e436985cf08ede8f667c429c37e21e9f433f6b24a5f4c5259e71ec22bcf5c4cf"],"executor":"worker","fingerprint":"sha256:5fd2a7d7cf164c9a2627faae7148144fd172d57c3b3530a90a4d61ef53ee68a6","id":"sha256:831f8d5b289ae3912bcc93aa71139fa9df7392fd6d6b817c3ad8a06c7be6ed2f","implementation_owner":"worker","merge_gate":{"reasons":[],"status":"READY"},"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"finish_merge","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/app.py"],"symbols":[],"total_paths":1},"reasons":[],"revision":{"base":"cccccccccccccccccccccccccccccccccccccccc","head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"source":"change:10","source_binding":{"contract":"devostasis.work-source.v1","kind":"CHANGE","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"10","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}},"title":"Add the feature"},{"acceptance":["Record an independent verdict on this exact head and scope."],"action":"review","continuation":false,"criterion":null,"dependencies":[],"eligibility":"READY","evidence":["sha256:271481e214a15c2b1ae112382fadd7019cb7da1d407946ecb115821df81ae8c9","sha256:83eb43051660be4e97b1da475c092bb6ccd7a2236d863880752bab3c26742f48","sha256:e436985cf08ede8f667c429c37e21e9f433f6b24a5f4c5259e71ec22bcf5c4cf"],"executor":"worker","fingerprint":"sha256:0e473667198cd4bdccecd80912491b5827e6a365b3da5ea434c2d6912550256b","id":"sha256:cea5d7476fdb1291a2f5ef31bc5e706778eb6efb538b85cc5b5d963d19dd9a29","implementation_owner":null,"merge_gate":null,"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"review","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/app.py"],"symbols":[],"total_paths":1},"reasons":[],"revision":{"base":"cccccccccccccccccccccccccccccccccccccccc","head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"source":"change:11","source_binding":{"contract":"devostasis.work-source.v1","kind":"CHANGE","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"11","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}},"title":"Review the parser"},{"acceptance":["Record a bounded reproducer and a rule verdict."],"action":"research","continuation":false,"criterion":"parser-question","dependencies":[],"eligibility":"READY","evidence":["sha256:271481e214a15c2b1ae112382fadd7019cb7da1d407946ecb115821df81ae8c9","sha256:83eb43051660be4e97b1da475c092bb6ccd7a2236d863880752bab3c26742f48","sha256:e436985cf08ede8f667c429c37e21e9f433f6b24a5f4c5259e71ec22bcf5c4cf"],"executor":"worker","fingerprint":"sha256:6c820abbd0cbb9f14377746592b7a427b4276db7fcfb7af4d888f38aa466d26b","id":"sha256:90e01a30cfbd76653abdda9110e68cec8bb281e32581d4db62f27731a7a6f1a4","implementation_owner":null,"merge_gate":null,"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"research","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/parser.py"],"symbols":["parse"],"total_paths":1},"reasons":[],"revision":{"content":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"source":"request:parser-question","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}},"title":"Which parser rule explains the recorded contradiction?"},{"acceptance":["Reproduce this exact test at the recorded revision and verify its expected behavior."],"action":"analyze_code","continuation":false,"criterion":"test-failure","dependencies":[],"eligibility":"READY","evidence":["sha256:271481e214a15c2b1ae112382fadd7019cb7da1d407946ecb115821df81ae8c9","sha256:83eb43051660be4e97b1da475c092bb6ccd7a2236d863880752bab3c26742f48","sha256:e436985cf08ede8f667c429c37e21e9f433f6b24a5f4c5259e71ec22bcf5c4cf","sha256:fbc111bcc1e072a64b0ad705a5044dfc413da6abf39c8c5e4b93d9ae04f6fb71"],"executor":"worker","fingerprint":"sha256:6245e9e764d410ad30ca2edf3f2f4fdb044b86d8560dd5d244eb08b19701793f","id":"sha256:1bbbb2fff8d71f3ba3b60dd8a930d7de40d0bc7133e74f8dd7b78535e966ea63","implementation_owner":null,"merge_gate":null,"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"analyze_code","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/app.py"],"symbols":["test_invalid"],"total_paths":1},"reasons":[],"revision":{"content":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"source":"sha256:72b814d58402f4b5e53fab7af673e1d2a735d53232ea870adf97e0c8b5e81ef5","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}},"title":"A test failure, error or explicit flaky rerun was reported."},{"acceptance":["Resolve the listed blockers and recheck this exact head before any authorized merge."],"action":"rework","continuation":false,"criterion":null,"dependencies":[],"eligibility":"BLOCKED","evidence":["sha256:271481e214a15c2b1ae112382fadd7019cb7da1d407946ecb115821df81ae8c9","sha256:83eb43051660be4e97b1da475c092bb6ccd7a2236d863880752bab3c26742f48","sha256:e436985cf08ede8f667c429c37e21e9f433f6b24a5f4c5259e71ec22bcf5c4cf"],"executor":"worker","fingerprint":"sha256:2be5f651652b265168adcf6c976d5126dfeebe2df7291e35183b80adb9a91b30","id":"sha256:747302f672eb5452b56f574af8513024297d77544b8499ec26d5aa2440cda4cd","implementation_owner":"other","merge_gate":{"reasons":["EXACT_HEAD_APPROVAL_REQUIRED"],"status":"BLOCKED"},"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"finish_merge","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/app.py"],"symbols":[],"total_paths":1},"reasons":["FOREIGN_IMPLEMENTATION_OWNER"],"revision":{"base":"cccccccccccccccccccccccccccccccccccccccc","head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},"source":"change:11","source_binding":{"contract":"devostasis.work-source.v1","kind":"CHANGE","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"11","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}},"title":"Review the parser"}],"kind":"scope","observed_at":"2026-10-02T12:00:00Z","planned_at":"2026-10-02T12:00:00Z","policy_version":"widget-1","queues":{"analyze_code":["sha256:1bbbb2fff8d71f3ba3b60dd8a930d7de40d0bc7133e74f8dd7b78535e966ea63"],"finish_merge":["sha256:831f8d5b289ae3912bcc93aa71139fa9df7392fd6d6b817c3ad8a06c7be6ed2f","sha256:747302f672eb5452b56f574af8513024297d77544b8499ec26d5aa2440cda4cd"],"implement_issue":["sha256:939096a134dafc3bad31f2fd780c7a8097923a4730d842d5874d5f5a8f475870"],"research":["sha256:90e01a30cfbd76653abdda9110e68cec8bb281e32581d4db62f27731a7a6f1a4"],"review":["sha256:cea5d7476fdb1291a2f5ef31bc5e706778eb6efb538b85cc5b5d963d19dd9a29"]},"selection":{"changes":"OPEN_AND_REGISTERED","issues":["20"]},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}}} \ No newline at end of file diff --git a/examples/work/evidence.json b/examples/work/evidence.json index 8ddf48b..0b95fe8 100644 --- a/examples/work/evidence.json +++ b/examples/work/evidence.json @@ -1 +1 @@ -{"contract":"devostasis.work.v1","kind":"evidence","sources":[{"observed_at":"2026-10-02T12:00:00Z","producer":"pytest","profile":"junit.v1","reason":null,"report":{"base64":"PHRlc3RzdWl0ZT48dGVzdGNhc2UgZmlsZT0ic3JjL2FwcC5weSIgbGluZT0iMTIiIG5hbWU9InRlc3RfaW52YWxpZCI+PGZhaWx1cmUvPjwvdGVzdGNhc2U+PC90ZXN0c3VpdGU+","digest":"sha256:fbc111bcc1e072a64b0ad705a5044dfc413da6abf39c8c5e4b93d9ae04f6fb71"},"status":"COMPLETE","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"version":"8-profile-1"}]} \ No newline at end of file +{"contract":"devostasis.work.v2","kind":"evidence","sources":[{"observed_at":"2026-10-02T12:00:00Z","producer":"pytest","profile":"junit.v1","reason":null,"report":{"base64":"PHRlc3RzdWl0ZT48dGVzdGNhc2UgZmlsZT0ic3JjL2FwcC5weSIgbGluZT0iMTIiIG5hbWU9InRlc3RfaW52YWxpZCI+PGZhaWx1cmUvPjwvdGVzdGNhc2U+PC90ZXN0c3VpdGU+","digest":"sha256:fbc111bcc1e072a64b0ad705a5044dfc413da6abf39c8c5e4b93d9ae04f6fb71"},"status":"COMPLETE","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}},"version":"8-profile-1"}]} \ No newline at end of file diff --git a/examples/work/github-observe.yml b/examples/work/github-observe.yml index 88f5744..29540a8 100644 --- a/examples/work/github-observe.yml +++ b/examples/work/github-observe.yml @@ -1,4 +1,4 @@ -# Copy into an adopter's .github/workflows directory after v0.3.0 is released. +# Copy into an adopter's .github/workflows directory after v0.4.0 is released. name: Work scope on: push: @@ -14,7 +14,7 @@ permissions: checks: read jobs: observe: - uses: drevendev/Devostasis/.github/workflows/work-observe.yml@v0.3.0 + uses: drevendev/Devostasis/.github/workflows/work-observe.yml@v0.4.0 with: policy-path: .devostasis/work-policy.json # This observer is deliberately not a required merge check. Artifacts are diff --git a/examples/work/gitlab-observe.yml b/examples/work/gitlab-observe.yml index b55a700..397686f 100644 --- a/examples/work/gitlab-observe.yml +++ b/examples/work/gitlab-observe.yml @@ -2,7 +2,7 @@ # The runner has a mounted private durable volume at WORK_HISTORY_ROOT. Only # protected canonical jobs can write it. It must not be inside the observed repo. variables: - DEVOSTASIS_REF: "v0.3.0" + DEVOSTASIS_REF: "v0.4.0" WORK_POLICY: ".devostasis/work-policy.json" .work-observer: @@ -23,9 +23,20 @@ variables: test -n "$WORK_HISTORY_ROOT" store="$WORK_HISTORY_ROOT" fi + # Pass the mutable MR ref only to candidate collection. Its native source + # project (including forks) is resolved and bound by the collector. + set -- + revision="$CI_COMMIT_SHA" + if [ "$context" = CANDIDATE ]; then + test -n "$CI_MERGE_REQUEST_IID" + set -- --source-change "$CI_MERGE_REQUEST_IID" + # Merged-results pipelines build a synthetic commit. Bind source content + # to the actual MR head, whose evidence the collector resolves. + revision="${CI_MERGE_REQUEST_SOURCE_BRANCH_SHA:-$CI_COMMIT_SHA}" + fi python -m devostasis work run --provider gitlab --endpoint "$CI_API_V4_URL" \ - --repo "$CI_PROJECT_ID" --policy "$WORK_POLICY" --revision "$CI_COMMIT_SHA" \ - --context "$context" --store "$store" --max-requests 100 --max-pages 5 --seconds 120 \ + --repo "$CI_PROJECT_ID" --policy "$WORK_POLICY" --revision "$revision" \ + --context "$context" "$@" --store "$store" --max-requests 100 --max-pages 5 --seconds 120 \ > work-output/result.json 2> work-output/failure.txt python - <<'PY' import json, pathlib, shutil, subprocess, sys diff --git a/examples/work/inventory.json b/examples/work/inventory.json index e591d68..e2c3b2d 100644 --- a/examples/work/inventory.json +++ b/examples/work/inventory.json @@ -1 +1 @@ -{"changes":{"items":[{"author":"worker","base":"cccccccccccccccccccccccccccccccccccccccc","checks":{"items":[{"at":"2026-10-02T12:00:00Z","id":"c10","name":"tests","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"PASS"}],"reasons":[],"status":"COMPLETE"},"conflict":false,"draft":false,"files":{"items":["src/app.py"],"reasons":[],"status":"COMPLETE"},"head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","id":"10","merge_train":false,"owners":[],"reviewers":[],"reviews":{"items":[{"actor":"reviewer","at":"2026-10-02T12:00:00Z","id":"r10","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"APPROVED"}],"reasons":[],"status":"COMPLETE"},"state":"OPEN","threads":{"items":[],"reasons":[],"status":"COMPLETE"},"title":"Add the feature","updated_at":"2026-10-02T12:00:00Z"},{"author":"other","base":"cccccccccccccccccccccccccccccccccccccccc","checks":{"items":[{"at":"2026-10-02T12:00:00Z","id":"c10","name":"tests","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"PASS"}],"reasons":[],"status":"COMPLETE"},"conflict":false,"draft":false,"files":{"items":["src/app.py"],"reasons":[],"status":"COMPLETE"},"head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","id":"11","merge_train":false,"owners":[],"reviewers":[],"reviews":{"items":[],"reasons":[],"status":"COMPLETE"},"state":"OPEN","threads":{"items":[],"reasons":[],"status":"COMPLETE"},"title":"Review the parser","updated_at":"2026-10-02T12:00:00Z"}],"reasons":[],"status":"COMPLETE"},"contract":"devostasis.work.v1","issues":{"items":[{"id":"20","owners":["worker"],"state":"OPEN","title":"Add validation","updated_at":"2026-10-02T12:00:00Z"}],"reasons":[],"status":"COMPLETE"},"kind":"inventory","observed_at":"2026-10-02T12:00:00Z","receipts":[],"selection":{"changes":"OPEN_AND_REGISTERED","issues":["20"]},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}} \ No newline at end of file +{"changes":{"items":[{"author":"worker","base":"cccccccccccccccccccccccccccccccccccccccc","checks":{"items":[{"at":"2026-10-02T12:00:00Z","id":"c10","name":"tests","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"PASS"}],"reasons":[],"status":"COMPLETE"},"conflict":false,"draft":false,"files":{"items":["src/app.py"],"reasons":[],"status":"COMPLETE"},"head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","id":"10","merge_train":false,"owners":[],"reviewers":[],"reviews":{"items":[{"actor":"reviewer","at":"2026-10-02T12:00:00Z","id":"r10","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"APPROVED"}],"reasons":[],"status":"COMPLETE"},"source_binding":{"contract":"devostasis.work-source.v1","kind":"CHANGE","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"10","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}},"state":"OPEN","threads":{"items":[],"reasons":[],"status":"COMPLETE"},"title":"Add the feature","updated_at":"2026-10-02T12:00:00Z"},{"author":"other","base":"cccccccccccccccccccccccccccccccccccccccc","checks":{"items":[{"at":"2026-10-02T12:00:00Z","id":"c10","name":"tests","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","state":"PASS"}],"reasons":[],"status":"COMPLETE"},"conflict":false,"draft":false,"files":{"items":["src/app.py"],"reasons":[],"status":"COMPLETE"},"head":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","id":"11","merge_train":false,"owners":[],"reviewers":[],"reviews":{"items":[],"reasons":[],"status":"COMPLETE"},"source_binding":{"contract":"devostasis.work-source.v1","kind":"CHANGE","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"11","revision":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}},"state":"OPEN","threads":{"items":[],"reasons":[],"status":"COMPLETE"},"title":"Review the parser","updated_at":"2026-10-02T12:00:00Z"}],"reasons":[],"status":"COMPLETE"},"contract":"devostasis.work.v2","issues":{"items":[{"id":"20","owners":["worker"],"state":"OPEN","title":"Add validation","updated_at":"2026-10-02T12:00:00Z"}],"reasons":[],"status":"COMPLETE"},"kind":"inventory","observed_at":"2026-10-02T12:00:00Z","receipts":[],"selection":{"changes":"OPEN_AND_REGISTERED","issues":["20"]},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}}} \ No newline at end of file diff --git a/examples/work/policy.json b/examples/work/policy.json index cf9198b..a740152 100644 --- a/examples/work/policy.json +++ b/examples/work/policy.json @@ -1 +1 @@ -{"actor":"worker","allow_merge_train":false,"contract":"devostasis.work.v1","criteria":[{"acceptance":["Reject an invalid input and accept a valid input."],"dependencies":[],"done":false,"id":"validation","implementations":[],"issue":"20","paths":["src/app.py"],"symbols":["validate"]}],"dispositions":[],"kind":"policy","minimum_approvals":1,"priorities":{"issue:20":{"rank":0,"rationale":"Explicit customer acceptance."}},"queue_order":["review","finish_merge","implement_issue","research","analyze_code"],"read_budget":{"max_bytes":200000,"max_files":20},"requests":[{"acceptance":["Record a bounded reproducer and a rule verdict."],"dependencies":[],"id":"parser-question","owner":null,"paths":["src/parser.py"],"question":"Which parser rule explains the recorded contradiction?","queue":"research","symbols":["parse"]}],"required_checks":["tests"],"roles":["all"],"ttl_seconds":3600,"version":"widget-1"} \ No newline at end of file +{"actor":"worker","allow_merge_train":false,"contract":"devostasis.work.v2","criteria":[{"acceptance":["Reject an invalid input and accept a valid input."],"dependencies":[],"done":false,"id":"validation","implementations":[],"issue":"20","paths":["src/app.py"],"symbols":["validate"]}],"dispositions":[],"kind":"policy","minimum_approvals":1,"priorities":{"issue:20":{"rank":0,"rationale":"Explicit customer acceptance."}},"queue_order":["review","finish_merge","implement_issue","research","analyze_code"],"read_budget":{"max_bytes":200000,"max_files":20},"requests":[{"acceptance":["Record a bounded reproducer and a rule verdict."],"dependencies":[],"id":"parser-question","owner":null,"paths":["src/parser.py"],"question":"Which parser rule explains the recorded contradiction?","queue":"research","symbols":["parse"]}],"required_checks":["tests"],"roles":["all"],"ttl_seconds":3600,"version":"widget-1"} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index b6fda53..dd563a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "devostasis" -version = "0.3.0" +version = "0.4.0" description = "Deterministic, model-free vital signs for software repositories." readme = "README.md" license = "MIT" diff --git a/schemas/execution-receipt.schema.json b/schemas/execution-receipt.schema.json new file mode 100644 index 0000000..709dbd7 --- /dev/null +++ b/schemas/execution-receipt.schema.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Non-canonical current execution audit; never stored in a bundle", + "type": "object", + "required": [ + "schema", + "run_id", + "started_at", + "ended_at", + "result_bundle_id", + "diagnostics", + "run_meta", + "receipt_identity" + ], + "properties": { + "schema": { + "const": "devostasis.execution-receipt.v1" + }, + "run_id": { + "type": "string" + }, + "started_at": { + "type": "string" + }, + "ended_at": { + "type": "string" + }, + "result_bundle_id": { + "type": [ + "string", + "null" + ] + }, + "diagnostics": { + "type": "array", + "items": { + "type": "string" + } + }, + "run_meta": { + "type": "object" + }, + "receipt_identity": { + "oneOf": [ + { + "$ref": "receipt-identity.schema.json" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "$id": "https://github.com/drevendev/devostasis/schemas/execution-receipt.schema.json" +} diff --git a/schemas/manifest.schema.json b/schemas/manifest.schema.json index abf0644..de91310 100644 --- a/schemas/manifest.schema.json +++ b/schemas/manifest.schema.json @@ -1,58 +1,13 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/drevendev/devostasis/schemas/manifest.schema.json", - "title": "Bundle manifest (devostasis.manifest.v1)", - "description": "Post-identity description of an immutable bundle. bundle_id is SHA-256 over the canonical identity_preimage; member digests and run_meta are outside the preimage (acyclic identity).", - "type": "object", - "required": ["schema", "bundle_id", "project_key", "project_identity", "observed_at", "previous_bundle_id", "comparison_status", "supersedes_bundle_id", "artifact_contract_version", "bundle_identity_contract", "vitals_contract_version", "observation_contract_version", "ci_unit_contract_version", "policy_version", "config_version", "renderer_version", "canonical_serialization_version", "effective_config_contract", "effective_config_digest", "semantic_config", "canonical_member_profile", "adapters", "receipt", "identity_preimage", "members", "run_meta"], - "properties": { - "schema": { "const": "devostasis.manifest.v1" }, - "bundle_id": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, - "project_key": { "type": "string" }, - "project_identity": { - "type": "object", - "required": ["provider", "forge_instance", "immutable_project_id", "display_locator"], - "properties": { - "provider": { "type": "string" }, - "forge_instance": { "type": "string" }, - "immutable_project_id": { "type": ["string", "null"] }, - "display_locator": { "type": "string" }, - "default_branch": { "type": ["string", "null"] } - } + "title": "Exact historical and successor manifest schemas", + "oneOf": [ + { + "$ref": "manifest.v1.schema.json" }, - "observed_at": { "type": "string", "format": "date-time" }, - "previous_bundle_id": { "type": ["string", "null"] }, - "comparison_status": { "enum": ["BASELINE", "COMPARABLE", "HISTORY_GAP", "INCOMPARABLE"] }, - "supersedes_bundle_id": { "type": ["string", "null"] }, - "artifact_contract_version": { "type": "string" }, - "bundle_identity_contract": { "const": "PV-BUNDLE-ID-002" }, - "vitals_contract_version": { "const": "PV-VITALS-V1-002" }, - "observation_contract_version": { "const": "RAW-OBS-V0" }, - "ci_unit_contract_version": { "const": "PV-CI-UNIT-004" }, - "policy_version": { "type": "string" }, - "config_version": { "type": "string" }, - "renderer_version": { "type": "string" }, - "canonical_serialization_version": { "type": "string" }, - "effective_config_contract": { "const": "PV-EFFECTIVE-CONFIG-001" }, - "effective_config_authority_contract": { "const": "PV-EFFECTIVE-CONFIG-AUTHORITY-001" }, - "effective_config_digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, - "semantic_config": { "type": "object" }, - "canonical_member_profile": { "type": "object" }, - "adapters": { "type": "array", "items": { "type": "object", "required": ["provider", "adapter_version"] } }, - "receipt": { "type": "object" }, - "gauge_contract": { "const": "devostasis.gauge.v1" }, - "demand_contract": { "enum": ["devostasis.demand.v1", "devostasis.demand.v2"] }, - "identity_preimage": { - "type": "object", - "required": ["bundle_identity_contract", "artifact_contract_version", "vitals_contract_version", "observation_contract_version", "policy_version", "config_version", "renderer_version", "canonical_serialization_version", "effective_config_contract", "effective_config_digest", "project_identity", "observed_at", "previous_bundle_id", "comparison_status", "snapshot_digest", "delta_digest", "activity_digest", "observations_digest", "source_receipts_digest"], - "not": { "anyOf": [{ "required": ["bundle_id"] }, { "required": ["members"] }, { "required": ["run_meta"] }] } - }, - "members": { - "type": "object", - "required": ["snapshot.json", "delta.json", "effective-config.json", "report.md"], - "additionalProperties": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" } - }, - "run_meta": { "type": "object" } - }, - "additionalProperties": false + { + "$ref": "manifest.v2.schema.json" + } + ], + "$id": "https://github.com/drevendev/devostasis/schemas/manifest.schema.json" } diff --git a/schemas/manifest.v1.schema.json b/schemas/manifest.v1.schema.json new file mode 100644 index 0000000..75b16e2 --- /dev/null +++ b/schemas/manifest.v1.schema.json @@ -0,0 +1,228 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/drevendev/devostasis/schemas/manifest.v1.schema.json", + "title": "Bundle manifest (devostasis.manifest.v1)", + "description": "Post-identity description of an immutable bundle. bundle_id is SHA-256 over the canonical identity_preimage; member digests and run_meta are outside the preimage (acyclic identity).", + "type": "object", + "required": [ + "schema", + "bundle_id", + "project_key", + "project_identity", + "observed_at", + "previous_bundle_id", + "comparison_status", + "supersedes_bundle_id", + "artifact_contract_version", + "bundle_identity_contract", + "vitals_contract_version", + "observation_contract_version", + "ci_unit_contract_version", + "policy_version", + "config_version", + "renderer_version", + "canonical_serialization_version", + "effective_config_contract", + "effective_config_digest", + "semantic_config", + "canonical_member_profile", + "adapters", + "receipt", + "identity_preimage", + "members", + "run_meta" + ], + "properties": { + "schema": { + "const": "devostasis.manifest.v1" + }, + "bundle_id": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "project_key": { + "type": "string" + }, + "project_identity": { + "type": "object", + "required": [ + "provider", + "forge_instance", + "immutable_project_id", + "display_locator" + ], + "properties": { + "provider": { + "type": "string" + }, + "forge_instance": { + "type": "string" + }, + "immutable_project_id": { + "type": [ + "string", + "null" + ] + }, + "display_locator": { + "type": "string" + }, + "default_branch": { + "type": [ + "string", + "null" + ] + } + } + }, + "observed_at": { + "type": "string", + "format": "date-time" + }, + "previous_bundle_id": { + "type": [ + "string", + "null" + ] + }, + "comparison_status": { + "enum": [ + "BASELINE", + "COMPARABLE", + "HISTORY_GAP", + "INCOMPARABLE" + ] + }, + "supersedes_bundle_id": { + "type": [ + "string", + "null" + ] + }, + "artifact_contract_version": { + "type": "string" + }, + "bundle_identity_contract": { + "const": "PV-BUNDLE-ID-002" + }, + "vitals_contract_version": { + "const": "PV-VITALS-V1-002" + }, + "observation_contract_version": { + "const": "RAW-OBS-V0" + }, + "ci_unit_contract_version": { + "const": "PV-CI-UNIT-004" + }, + "policy_version": { + "type": "string" + }, + "config_version": { + "type": "string" + }, + "renderer_version": { + "type": "string" + }, + "canonical_serialization_version": { + "type": "string" + }, + "effective_config_contract": { + "const": "PV-EFFECTIVE-CONFIG-001" + }, + "effective_config_authority_contract": { + "const": "PV-EFFECTIVE-CONFIG-AUTHORITY-001" + }, + "effective_config_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "semantic_config": { + "type": "object" + }, + "canonical_member_profile": { + "type": "object" + }, + "adapters": { + "type": "array", + "items": { + "type": "object", + "required": [ + "provider", + "adapter_version" + ] + } + }, + "receipt": { + "type": "object" + }, + "gauge_contract": { + "const": "devostasis.gauge.v1" + }, + "demand_contract": { + "enum": [ + "devostasis.demand.v1", + "devostasis.demand.v2" + ] + }, + "identity_preimage": { + "type": "object", + "required": [ + "bundle_identity_contract", + "artifact_contract_version", + "vitals_contract_version", + "observation_contract_version", + "policy_version", + "config_version", + "renderer_version", + "canonical_serialization_version", + "effective_config_contract", + "effective_config_digest", + "project_identity", + "observed_at", + "previous_bundle_id", + "comparison_status", + "snapshot_digest", + "delta_digest", + "activity_digest", + "observations_digest", + "source_receipts_digest" + ], + "not": { + "anyOf": [ + { + "required": [ + "bundle_id" + ] + }, + { + "required": [ + "members" + ] + }, + { + "required": [ + "run_meta" + ] + } + ] + } + }, + "members": { + "type": "object", + "required": [ + "snapshot.json", + "delta.json", + "effective-config.json", + "report.md" + ], + "additionalProperties": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + } + }, + "run_meta": { + "type": "object" + } + }, + "additionalProperties": false +} diff --git a/schemas/manifest.v2.schema.json b/schemas/manifest.v2.schema.json new file mode 100644 index 0000000..79367a5 --- /dev/null +++ b/schemas/manifest.v2.schema.json @@ -0,0 +1,372 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/drevendev/devostasis/schemas/manifest.v2.schema.json", + "title": "Canonical manifest (devostasis.manifest.v2)", + "description": "PV-BUNDLE-ID-003: complete receipt identity is always durable here; invocation receipts remain outside every canonical member.", + "type": "object", + "required": [ + "schema", + "bundle_id", + "project_key", + "project_identity", + "observed_at", + "previous_bundle_id", + "comparison_status", + "supersedes_bundle_id", + "artifact_contract_version", + "bundle_identity_contract", + "vitals_contract_version", + "observation_contract_version", + "ci_unit_contract_version", + "policy_version", + "config_version", + "renderer_version", + "canonical_serialization_version", + "effective_config_contract", + "effective_config_authority_contract", + "effective_config_digest", + "semantic_config", + "canonical_member_profile", + "adapters", + "gauge_contract", + "demand_contract", + "identity_preimage", + "members", + "receipt_identity", + "manifest_schema", + "receipt_identity_schema", + "receipt_identity_contract", + "observations_schema" + ], + "properties": { + "schema": { + "const": "devostasis.manifest.v2" + }, + "bundle_id": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "project_key": { + "type": "string" + }, + "project_identity": { + "type": "object", + "required": [ + "provider", + "forge_instance", + "immutable_project_id", + "display_locator" + ], + "properties": { + "provider": { + "type": "string" + }, + "forge_instance": { + "type": "string" + }, + "immutable_project_id": { + "type": [ + "string", + "null" + ] + }, + "display_locator": { + "type": "string" + }, + "default_branch": { + "type": [ + "string", + "null" + ] + } + } + }, + "observed_at": { + "type": "string", + "format": "date-time" + }, + "previous_bundle_id": { + "type": [ + "string", + "null" + ] + }, + "comparison_status": { + "enum": [ + "BASELINE", + "COMPARABLE", + "HISTORY_GAP", + "INCOMPARABLE" + ] + }, + "supersedes_bundle_id": { + "const": null + }, + "artifact_contract_version": { + "const": "devostasis.bundle.v3" + }, + "bundle_identity_contract": { + "const": "PV-BUNDLE-ID-003" + }, + "vitals_contract_version": { + "const": "PV-VITALS-V1-002" + }, + "observation_contract_version": { + "const": "RAW-OBS-V0" + }, + "ci_unit_contract_version": { + "const": "PV-CI-UNIT-004" + }, + "policy_version": { + "type": "string" + }, + "config_version": { + "type": "string" + }, + "renderer_version": { + "const": "devostasis.render.v5" + }, + "canonical_serialization_version": { + "type": "string" + }, + "effective_config_contract": { + "const": "PV-EFFECTIVE-CONFIG-001" + }, + "effective_config_authority_contract": { + "const": "PV-EFFECTIVE-CONFIG-AUTHORITY-001" + }, + "effective_config_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "semantic_config": { + "type": "object" + }, + "canonical_member_profile": { + "type": "object" + }, + "adapters": { + "type": "array", + "items": { + "type": "object", + "required": [ + "provider", + "adapter_version" + ] + } + }, + "gauge_contract": { + "const": "devostasis.gauge.v1" + }, + "demand_contract": { + "const": "devostasis.demand.v2" + }, + "identity_preimage": { + "type": "object", + "required": [ + "bundle_identity_contract", + "artifact_contract_version", + "vitals_contract_version", + "observation_contract_version", + "ci_unit_contract_version", + "policy_version", + "config_version", + "renderer_version", + "canonical_serialization_version", + "effective_config_contract", + "effective_config_digest", + "project_identity", + "observed_at", + "previous_bundle_id", + "comparison_status", + "snapshot_digest", + "delta_digest", + "activity_digest", + "observations_digest", + "source_receipts_digest", + "gauge_contract", + "demand_contract", + "gauges_digest", + "demand_digest", + "manifest_schema", + "observations_schema", + "receipt_identity_contract", + "receipt_identity_schema" + ], + "not": { + "anyOf": [ + { + "required": [ + "bundle_id" + ] + }, + { + "required": [ + "members" + ] + }, + { + "required": [ + "run_meta" + ] + } + ] + }, + "additionalProperties": false, + "properties": { + "bundle_identity_contract": { + "const": "PV-BUNDLE-ID-003" + }, + "artifact_contract_version": { + "const": "devostasis.bundle.v3" + }, + "vitals_contract_version": { + "const": "PV-VITALS-V1-002" + }, + "observation_contract_version": { + "const": "RAW-OBS-V0" + }, + "ci_unit_contract_version": { + "const": "PV-CI-UNIT-004" + }, + "policy_version": { + "type": "string" + }, + "config_version": { + "type": "string" + }, + "renderer_version": { + "const": "devostasis.render.v5" + }, + "canonical_serialization_version": { + "type": "string" + }, + "effective_config_contract": { + "const": "PV-EFFECTIVE-CONFIG-001" + }, + "effective_config_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "project_identity": { + "type": "object", + "required": [ + "provider", + "forge_instance", + "immutable_project_id", + "display_locator" + ], + "properties": { + "provider": { + "type": "string" + }, + "forge_instance": { + "type": "string" + }, + "immutable_project_id": { + "type": [ + "string", + "null" + ] + }, + "display_locator": { + "type": "string" + }, + "default_branch": { + "type": [ + "string", + "null" + ] + } + } + }, + "observed_at": { + "type": "string", + "format": "date-time" + }, + "previous_bundle_id": { + "type": [ + "string", + "null" + ] + }, + "comparison_status": { + "enum": [ + "BASELINE", + "COMPARABLE", + "HISTORY_GAP", + "INCOMPARABLE" + ] + }, + "snapshot_digest": { + "type": "string" + }, + "delta_digest": { + "type": "string" + }, + "activity_digest": { + "type": "string" + }, + "observations_digest": { + "type": "string" + }, + "source_receipts_digest": { + "type": "string" + }, + "gauge_contract": { + "const": "devostasis.gauge.v1" + }, + "demand_contract": { + "const": "devostasis.demand.v2" + }, + "gauges_digest": { + "type": "string" + }, + "demand_digest": { + "type": "string" + }, + "manifest_schema": { + "const": "devostasis.manifest.v2" + }, + "observations_schema": { + "const": "devostasis.observations.v2" + }, + "receipt_identity_contract": { + "const": "PV-RECEIPT-IDENTITY-003" + }, + "receipt_identity_schema": { + "const": "devostasis.receipt-identity.v1" + } + } + }, + "members": { + "type": "object", + "required": [ + "snapshot.json", + "delta.json", + "effective-config.json", + "report.md" + ], + "additionalProperties": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + } + }, + "receipt_identity": { + "$ref": "receipt-identity.schema.json" + }, + "manifest_schema": { + "const": "devostasis.manifest.v2" + }, + "receipt_identity_schema": { + "const": "devostasis.receipt-identity.v1" + }, + "receipt_identity_contract": { + "const": "PV-RECEIPT-IDENTITY-003" + }, + "observations_schema": { + "const": "devostasis.observations.v2" + } + }, + "additionalProperties": false +} diff --git a/schemas/observations.schema.json b/schemas/observations.schema.json index 54257db..c535eb5 100644 --- a/schemas/observations.schema.json +++ b/schemas/observations.schema.json @@ -1,57 +1,13 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/drevendev/devostasis/schemas/observations.schema.json", - "title": "Observation set with collection receipt (devostasis.observations.v1)", - "type": "object", - "required": ["schema", "observation_contract_version", "subject", "observed_at", "observations", "receipt"], - "properties": { - "schema": { "const": "devostasis.observations.v1" }, - "observation_contract_version": { "const": "RAW-OBS-V0" }, - "subject": { - "type": "object", - "properties": { - "provider": { "type": "string" }, - "forge_instance": { "type": "string" }, - "owner": { "type": "string" }, - "repo": { "type": "string" }, - "display_locator": { "type": "string" }, - "immutable_project_id": { "type": ["string", "null"] }, - "default_branch": { "type": ["string", "null"] }, - "visibility": { "type": ["string", "null"] } - } + "title": "Exact historical and successor observations schemas", + "oneOf": [ + { + "$ref": "observations.v1.schema.json" }, - "observed_at": { "type": "string", "format": "date-time" }, - "observations": { - "type": "array", - "items": { "$ref": "observation.schema.json" } - }, - "receipt": { - "type": ["object", "null"], - "required": ["schema", "run_id", "collector_version", "target", "requested_keys", "returned_keys", "per_key"], - "properties": { - "schema": { "enum": ["devostasis.receipt.v1", "devostasis.receipt.v2"] }, - "run_id": { "type": "string" }, - "collector_version": { "type": "string" }, - "target": { "type": "object" }, - "started_at": { "type": "string" }, - "ended_at": { "type": "string" }, - "requested_keys": { "type": "array", "items": { "type": "string" } }, - "returned_keys": { "type": "array", "items": { "type": "string" } }, - "per_key": { - "type": "object", - "additionalProperties": { - "type": "object", - "required": ["status", "freshness"], - "properties": { - "status": { "enum": ["AVAILABLE", "PARTIAL", "UNAVAILABLE", "FORBIDDEN", "UNKNOWN", "ERROR"] }, - "freshness": { "enum": ["FRESH", "STALE", "UNKNOWN"] } - } - } - }, - "capability_notes": { "type": "array", "items": { "type": "string" } }, - "config_hash": { "type": ["string", "null"] } - } + { + "$ref": "observations.v2.schema.json" } - }, - "additionalProperties": false + ], + "$id": "https://github.com/drevendev/devostasis/schemas/observations.schema.json" } diff --git a/schemas/observations.v1.schema.json b/schemas/observations.v1.schema.json new file mode 100644 index 0000000..cb9a48b --- /dev/null +++ b/schemas/observations.v1.schema.json @@ -0,0 +1,162 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/drevendev/devostasis/schemas/observations.v1.schema.json", + "title": "Observation set with collection receipt (devostasis.observations.v1)", + "type": "object", + "required": [ + "schema", + "observation_contract_version", + "subject", + "observed_at", + "observations", + "receipt" + ], + "properties": { + "schema": { + "const": "devostasis.observations.v1" + }, + "observation_contract_version": { + "const": "RAW-OBS-V0" + }, + "subject": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "forge_instance": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "repo": { + "type": "string" + }, + "display_locator": { + "type": "string" + }, + "immutable_project_id": { + "type": [ + "string", + "null" + ] + }, + "default_branch": { + "type": [ + "string", + "null" + ] + }, + "visibility": { + "type": [ + "string", + "null" + ] + } + } + }, + "observed_at": { + "type": "string", + "format": "date-time" + }, + "observations": { + "type": "array", + "items": { + "$ref": "observation.schema.json" + } + }, + "receipt": { + "type": [ + "object", + "null" + ], + "required": [ + "schema", + "run_id", + "collector_version", + "target", + "requested_keys", + "returned_keys", + "per_key" + ], + "properties": { + "schema": { + "enum": [ + "devostasis.receipt.v1", + "devostasis.receipt.v2" + ] + }, + "run_id": { + "type": "string" + }, + "collector_version": { + "type": "string" + }, + "target": { + "type": "object" + }, + "started_at": { + "type": "string" + }, + "ended_at": { + "type": "string" + }, + "requested_keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "returned_keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "per_key": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "status", + "freshness" + ], + "properties": { + "status": { + "enum": [ + "AVAILABLE", + "PARTIAL", + "UNAVAILABLE", + "FORBIDDEN", + "UNKNOWN", + "ERROR" + ] + }, + "freshness": { + "enum": [ + "FRESH", + "STALE", + "UNKNOWN" + ] + } + } + } + }, + "capability_notes": { + "type": "array", + "items": { + "type": "string" + } + }, + "config_hash": { + "type": [ + "string", + "null" + ] + } + } + } + }, + "additionalProperties": false +} diff --git a/schemas/observations.v2.schema.json b/schemas/observations.v2.schema.json new file mode 100644 index 0000000..95b358a --- /dev/null +++ b/schemas/observations.v2.schema.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/drevendev/devostasis/schemas/observations.v2.schema.json", + "title": "Canonical observation set (devostasis.observations.v2)", + "type": "object", + "required": [ + "schema", + "observation_contract_version", + "subject", + "observed_at", + "observations", + "receipt_identity" + ], + "properties": { + "schema": { + "const": "devostasis.observations.v2" + }, + "observation_contract_version": { + "const": "RAW-OBS-V0" + }, + "subject": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "forge_instance": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "repo": { + "type": "string" + }, + "display_locator": { + "type": "string" + }, + "immutable_project_id": { + "type": [ + "string", + "null" + ] + }, + "default_branch": { + "type": [ + "string", + "null" + ] + }, + "visibility": { + "type": [ + "string", + "null" + ] + } + } + }, + "observed_at": { + "type": "string", + "format": "date-time" + }, + "observations": { + "type": "array", + "items": { + "$ref": "observation.schema.json" + } + }, + "receipt_identity": { + "oneOf": [ + { + "$ref": "receipt-identity.schema.json" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false +} diff --git a/schemas/receipt-identity.schema.json b/schemas/receipt-identity.schema.json new file mode 100644 index 0000000..6ca5036 --- /dev/null +++ b/schemas/receipt-identity.schema.json @@ -0,0 +1,93 @@ +{ + "type": "object", + "required": [ + "schema", + "collector_version", + "target", + "requested_keys", + "returned_keys", + "per_key", + "capability_notes", + "config_hash", + "contract", + "coverage" + ], + "properties": { + "schema": { + "const": "devostasis.receipt-identity.v1" + }, + "collector_version": { + "type": "string" + }, + "target": { + "type": "object" + }, + "requested_keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "returned_keys": { + "type": "array", + "items": { + "type": "string" + } + }, + "per_key": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "status", + "freshness" + ], + "properties": { + "status": { + "enum": [ + "AVAILABLE", + "PARTIAL", + "UNAVAILABLE", + "FORBIDDEN", + "UNKNOWN", + "ERROR" + ] + }, + "freshness": { + "enum": [ + "FRESH", + "STALE", + "UNKNOWN" + ] + } + }, + "additionalProperties": false + } + }, + "capability_notes": { + "type": "array", + "items": { + "type": "string" + } + }, + "config_hash": { + "type": [ + "string", + "null" + ] + }, + "contract": { + "const": "PV-RECEIPT-IDENTITY-003" + }, + "coverage": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + }, + "additionalProperties": false, + "title": "Canonical receipt identity (PV-BUNDLE-ID-003)", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/drevendev/devostasis/schemas/receipt-identity.schema.json" +} diff --git a/scripts/make_examples.py b/scripts/make_examples.py index e565d6d..424b895 100644 --- a/scripts/make_examples.py +++ b/scripts/make_examples.py @@ -155,11 +155,13 @@ def main() -> int: normalize.derive(observation_set, project) store_dir = examples / ".store" if store_dir.exists(): + assert store_dir.resolve().parent == examples.resolve() and not store_dir.is_symlink() shutil.rmtree(store_dir) store = FilesystemHistoryStore(store_dir) bundle = build_from_observations(project, observation_set, store, run_meta={"example": True}) target = examples / "sample-bundle" if target.exists(): + assert target.resolve().parent == examples.resolve() and not target.is_symlink() shutil.rmtree(target) target.mkdir(parents=True) for name, data in bundle.members.items(): diff --git a/scripts/make_work_examples.py b/scripts/make_work_examples.py index ad0825a..7da355a 100644 --- a/scripts/make_work_examples.py +++ b/scripts/make_work_examples.py @@ -16,12 +16,18 @@ sha, head, base = "a" * 40, "b" * 40, "c" * 40 subject = {"provider": "github", "endpoint": "https://api.github.com", "project_id": "123456", "locator": "acme/widget", "revision": sha, "context": "CANONICAL"} +identity = {k: subject[k] for k in ("provider", "endpoint", "project_id")} +subject["source_binding"] = {"contract": "devostasis.work-source.v1", "kind": "DEFAULT_BRANCH", + "project": identity, "source_project": identity, "source_locator": "acme/widget", "ref": "main", "revision": sha} change = {"id": "10", "title": "Add the feature", "state": "OPEN", "author": "worker", "owners": [], "reviewers": [], "head": head, "base": base, "updated_at": at, "draft": False, "conflict": False, "merge_train": False, "reviews": complete([{"id": "r10", "actor": "reviewer", "revision": head, "state": "APPROVED", "at": at}]), "checks": complete([{"id": "c10", "name": "tests", "revision": head, "state": "PASS", "at": at}]), "threads": complete(), "files": complete(["src/app.py"])} other = {**change, "id": "11", "author": "other", "title": "Review the parser", "reviews": complete()} +for record in (change, other): + record["source_binding"] = {"contract": "devostasis.work-source.v1", "kind": "CHANGE", "project": identity, + "source_project": identity, "source_locator": "acme/widget", "ref": record["id"], "revision": head} inv = {"contract": CONTRACT, "kind": "inventory", "subject": subject, "observed_at": at, "changes": complete([change, other]), "issues": complete([{"id": "20", "title": "Add validation", "state": "OPEN", "owners": ["worker"], "updated_at": at}]), "receipts": [], diff --git a/src/devostasis/__init__.py b/src/devostasis/__init__.py index 7f49110..0b8c641 100644 --- a/src/devostasis/__init__.py +++ b/src/devostasis/__init__.py @@ -1,3 +1,3 @@ """Devostasis: deterministic, model-free vital signs for software repositories.""" -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/src/devostasis/activity.py b/src/devostasis/activity.py index 70126a1..88a4650 100644 --- a/src/devostasis/activity.py +++ b/src/devostasis/activity.py @@ -173,7 +173,7 @@ def build_activity( classes["RELEASE"] = {"count": len(release_items), "items": items} capability_changes: list[dict[str, Any]] = [] - previous_receipt = (previous_manifest or {}).get("receipt") or {} + previous_receipt = (previous_manifest or {}).get("receipt_identity") or (previous_manifest or {}).get("receipt") or {} prev_keys = previous_receipt.get("per_key") or {} cur_keys = obs.receipt.per_key if obs.receipt else {} if previous_manifest is not None: diff --git a/src/devostasis/bundle.py b/src/devostasis/bundle.py index 298d06d..e005237 100644 --- a/src/devostasis/bundle.py +++ b/src/devostasis/bundle.py @@ -33,13 +33,16 @@ EFFECTIVE_CONFIG_CONTRACT, GAUGE_CONTRACT, MANIFEST_SCHEMA, + OBSERVATIONS_SCHEMA, + RECEIPT_IDENTITY_CONTRACT, + RECEIPT_IDENTITY_SCHEMA, OBSERVATION_CONTRACT_VERSION, RENDERER_VERSION, VITALS_CONTRACT_VERSION, ) from .demand import build_demand from .gauges import gauges_member -from .observations import ObservationSet +from .observations import ObservationSet, validate_receipt_identity from .policy import POLICY_VERSION ACTIVITY_DISABLED = "ACTIVITY_DISABLED" @@ -79,6 +82,10 @@ # must agree: the preimage is what bundle_id commits to, the manifest copy is # what readers and the comparison read. DUPLICATED_IDENTITY_FIELDS = ( + "manifest_schema", + "observations_schema", + "receipt_identity_contract", + "receipt_identity_schema", "bundle_identity_contract", "artifact_contract_version", "vitals_contract_version", @@ -122,6 +129,16 @@ "source_receipts_digest", ) _PREIMAGE_V2 = _PREIMAGE_V1 + ("gauge_contract", "demand_contract", "gauges_digest", "demand_digest") +_PREIMAGE_V3 = _PREIMAGE_V2 + ("manifest_schema", "observations_schema", "receipt_identity_contract", "receipt_identity_schema") +MANIFEST_V2_FIELDS = frozenset({ + "schema", "bundle_id", "project_key", "project_identity", "observed_at", "previous_bundle_id", + "comparison_status", "supersedes_bundle_id", "artifact_contract_version", "bundle_identity_contract", + "vitals_contract_version", "observation_contract_version", "ci_unit_contract_version", "gauge_contract", + "demand_contract", "policy_version", "config_version", "renderer_version", "canonical_serialization_version", + "effective_config_contract", "effective_config_authority_contract", "effective_config_digest", "semantic_config", + "canonical_member_profile", "adapters", "receipt_identity", "identity_preimage", "manifest_schema", + "observations_schema", "receipt_identity_contract", "receipt_identity_schema", "members", +}) # The stored lineages verification dispatches on, keyed by the preimage's # ``artifact_contract_version`` as an exact token @@ -142,6 +159,10 @@ "preimage_fields": frozenset(_PREIMAGE_V2), "renderers": ("devostasis.render.v3", "devostasis.render.v4"), }, + "devostasis.bundle.v3": { + "preimage_fields": frozenset(_PREIMAGE_V3), + "renderers": ("devostasis.render.v5",), + }, } @@ -152,6 +173,7 @@ class Bundle: observed_at: str manifest: dict[str, Any] members: dict[str, bytes] = field(default_factory=dict) + execution_receipt: dict[str, Any] | None = None def _json(self, name: str) -> dict[str, Any]: return canonical.loads(self.members[name].decode("utf-8")) @@ -203,8 +225,9 @@ def build_bundle( effective_config = project.effective_bundle_config() effective_bytes = canonical.canonical_bytes(effective_config) effective_digest = canonical.digest_bytes(effective_bytes) - receipt_dict = obs.receipt.to_dict() + receipt_dict = obs.receipt_identity() identity = project_identity(obs, project) + canonical_key = identity["forge_instance"] + "/" + identity["display_locator"] member_profile = member_profile_from_config(effective_config) if (activity is None) != (member_profile["activity_json"] == "DISABLED"): raise BundleError("activity member presence disagrees with the effective configuration") @@ -222,6 +245,10 @@ def build_bundle( receipt_digest = canonical.digest(receipt_dict) preimage = { + "manifest_schema": MANIFEST_SCHEMA, + "observations_schema": OBSERVATIONS_SCHEMA, + "receipt_identity_contract": RECEIPT_IDENTITY_CONTRACT, + "receipt_identity_schema": RECEIPT_IDENTITY_SCHEMA, "bundle_identity_contract": BUNDLE_IDENTITY_CONTRACT, "artifact_contract_version": ARTIFACT_CONTRACT_VERSION, "vitals_contract_version": VITALS_CONTRACT_VERSION, @@ -250,9 +277,13 @@ def build_bundle( bundle_id = canonical.sha256_hex(canonical.canonical_bytes(preimage)) manifest_core = { + "manifest_schema": MANIFEST_SCHEMA, + "observations_schema": OBSERVATIONS_SCHEMA, + "receipt_identity_contract": RECEIPT_IDENTITY_CONTRACT, + "receipt_identity_schema": RECEIPT_IDENTITY_SCHEMA, "schema": MANIFEST_SCHEMA, "bundle_id": bundle_id, - "project_key": project.project_key, + "project_key": canonical_key, "project_identity": identity, "observed_at": obs.observed_at, "previous_bundle_id": previous_bundle_id, @@ -275,7 +306,7 @@ def build_bundle( "semantic_config": project.semantic_config(), "canonical_member_profile": member_profile, "adapters": [{"provider": "github", "adapter_version": obs.receipt.collector_version}], - "receipt": receipt_dict, + "receipt_identity": receipt_dict, "identity_preimage": preimage, } @@ -308,9 +339,10 @@ def build_bundle( manifest["members"]["activity.json"] = activity_digest if project.observations_member: manifest["members"]["observations.json"] = observations_digest - manifest["run_meta"] = dict(run_meta or {}, tool={"name": "devostasis", "version": __version__}) members["manifest.json"] = canonical.pretty_json(manifest).encode("utf-8") - return Bundle(bundle_id=bundle_id, project_key=project.project_key, observed_at=obs.observed_at, manifest=manifest, members=members) + execution = obs.receipt.execution(bundle_id, dict(run_meta or {}, tool={"name": "devostasis", "version": __version__}), receipt_dict) + return Bundle(bundle_id=bundle_id, project_key=canonical_key, observed_at=obs.observed_at, + manifest=manifest, members=members, execution_receipt=execution) def load_bundle_dir(directory: str | Path) -> dict[str, bytes]: @@ -414,7 +446,7 @@ def _check_adapters(manifest: dict[str, Any]) -> list[str]: collected under. """ identity = manifest.get("project_identity") - receipt = manifest.get("receipt") + receipt = manifest.get("receipt_identity") if manifest.get("schema") == MANIFEST_SCHEMA else manifest.get("receipt") expected = [ { "provider": identity.get("provider") if isinstance(identity, dict) else None, @@ -429,7 +461,14 @@ def _check_adapters(manifest: dict[str, Any]) -> list[str]: def _check_evidence_binding(members: dict[str, bytes], manifest: dict[str, Any], preimage: dict[str, Any]) -> list[str]: """The receipt and the evidence the manifest names must be the ones the identity hashes.""" problems: list[str] = [] - receipt = manifest.get("receipt") + successor = manifest.get("schema") == MANIFEST_SCHEMA + receipt_key = "receipt_identity" if successor else "receipt" + receipt = manifest.get(receipt_key) + if successor: + try: + validate_receipt_identity(receipt) + except (ValueError, TypeError) as exc: + problems.append(f"RECEIPT_IDENTITY_INVALID: {exc}") if "source_receipts_digest" in preimage: try: actual = canonical.digest(receipt) @@ -443,8 +482,12 @@ def _check_evidence_binding(members: dict[str, bytes], manifest: dict[str, Any], observations = canonical.loads(members["observations.json"].decode("utf-8")) except Exception: # noqa: BLE001 observations = None - if isinstance(observations, dict) and observations.get("receipt") != receipt: + if isinstance(observations, dict) and observations.get(receipt_key) != receipt: problems.append(f"{RECEIPT_COPY_MISMATCH}: the receipt in observations.json differs from the manifest receipt") + expected_schema = OBSERVATIONS_SCHEMA if successor else "devostasis.observations.v1" + if not isinstance(observations, dict) or observations.get("schema") != expected_schema or ( + "receipt" if successor else "receipt_identity") in observations: + problems.append("LINEAGE_MISMATCH: observations receipt schema") if "snapshot.json" in members and "observations.json" in (manifest.get("members") or {}): try: snapshot = canonical.loads(members["snapshot.json"].decode("utf-8")) @@ -552,6 +595,41 @@ def verify_members(members: dict[str, bytes]) -> list[str]: if problems: return problems + from .lineages import validate_bundle_lineage + problems.extend(validate_bundle_lineage(manifest, members)) + + successor = manifest.get("artifact_contract_version") == "devostasis.bundle.v3" + if successor: + expected = {"schema": MANIFEST_SCHEMA, "manifest_schema": MANIFEST_SCHEMA, + "bundle_identity_contract": BUNDLE_IDENTITY_CONTRACT, + "receipt_identity_contract": RECEIPT_IDENTITY_CONTRACT, + "receipt_identity_schema": RECEIPT_IDENTITY_SCHEMA, + "observations_schema": OBSERVATIONS_SCHEMA} + if any(manifest.get(k) != v for k, v in expected.items()) or "receipt" in manifest or "run_meta" in manifest: + problems.append("LINEAGE_MISMATCH: successor manifest/receipt contract") + if set(manifest) != MANIFEST_V2_FIELDS: + problems.append("MANIFEST_SCHEMA_INVALID: missing or unknown successor fields") + if manifest.get("supersedes_bundle_id") is not None or manifest.get("effective_config_authority_contract") != EFFECTIVE_CONFIG_AUTHORITY_CONTRACT: + problems.append("MANIFEST_SCHEMA_INVALID: unsupported authority or supersession") + project_ref = manifest.get("project_identity") + if not isinstance(project_ref, dict) or manifest.get("project_key") != ( + str(project_ref.get("forge_instance")) + "/" + str(project_ref.get("display_locator"))): + problems.append("MANIFEST_SCHEMA_INVALID: project key differs from bound identity") + for name, data in members.items(): + if name.endswith(".json"): + try: + value = canonical.loads(data.decode("utf-8")) + expected_bytes = canonical.canonical_bytes(value) if name == "effective-config.json" else canonical.pretty_json(value).encode("utf-8") + if data != expected_bytes: + problems.append("CANONICAL_BYTES_MISMATCH: " + name) + except (ValueError, TypeError, UnicodeError): + problems.append("CANONICAL_BYTES_MISMATCH: " + name) + if not set(manifest.get("members") or {}) <= set(MEMBER_NAMES) - {"manifest.json"}: + problems.append("CANONICAL_MEMBER_PROFILE_MISMATCH: unsupported successor member") + elif manifest.get("schema") != "devostasis.manifest.v1" or manifest.get("bundle_identity_contract") not in ( + "PV-BUNDLE-ID-001", "PV-BUNDLE-ID-002") or "receipt_identity" in manifest: + problems.append("LINEAGE_MISMATCH: historical manifest/receipt contract") + declared = manifest.get("members") or {} for name, digest in sorted(declared.items()): if name not in members: @@ -625,7 +703,7 @@ def verify_members(members: dict[str, bytes]) -> list[str]: if "report.md" in members: if not authority_ok: problems.append("report.md replay skipped: the stored effective config is not a valid authority (ART-24)") - elif "snapshot.json" in members and "delta.json" in members and manifest.get("renderer_version") == RENDERER_VERSION: + elif "snapshot.json" in members and "delta.json" in members and manifest.get("renderer_version") in ("devostasis.render.v4", "devostasis.render.v5"): try: snapshot = canonical.loads(members["snapshot.json"].decode("utf-8")) delta = canonical.loads(members["delta.json"].decode("utf-8")) diff --git a/src/devostasis/cli.py b/src/devostasis/cli.py index 907ee70..d6fe010 100644 --- a/src/devostasis/cli.py +++ b/src/devostasis/cli.py @@ -17,7 +17,7 @@ from .contracts import RENDERER_VERSION from .config import ConfigError, load_config, single_project from .history import FilesystemHistoryStore, HistoryStoreError -from .observations import ObservationSet +from .observations import ObservationSet, failed_execution from .runner import FleetSurfaceError, build_from_observations, evaluate, observe, run_all, write_fleet_index from .vitals.common import InadmissibleEvidence @@ -117,12 +117,19 @@ def cmd_observe(args: argparse.Namespace) -> int: project = single_project(args.repo, **_project_overrides(args)) client = GitHubClient(UrllibTransport(token), budget=args.request_budget, cache=_cache_for(args)) print(f"token: {source}", file=sys.stderr) + started_at = timeutil.format_ts(timeutil.now_utc()) try: obs = observe(project, client, _now(args.now)) except CollectionError as exc: + at = timeutil.format_ts(timeutil.now_utc()) + canonical.write_pretty(str(args.out) + ".execution-receipt.json", + failed_execution(project.locator, started_at, at, str(exc), {"requests": client.request_count})) print(f"error: {exc}", file=sys.stderr) return 1 obs.save(args.out) + canonical.write_pretty(str(args.out) + ".execution-receipt.json", obs.receipt.execution(run_meta={ + "requests": client.request_count, "billed_requests": client.billed_count, + "conditional_hits": client.conditional_hits, "retries": client.retries}, receipt_identity=obs.receipt_identity())) print(f"observations: {args.out} ({len(obs)} keys, {_requests_line(client)})") if client.cache is not None: try: @@ -194,6 +201,11 @@ def cmd_run(args: argparse.Namespace) -> int: return 1 failed = 0 for outcome in outcomes: + if outcome.execution_receipt is not None: + receipts = store.root / "executions" + receipts.mkdir(parents=True, exist_ok=True) + from uuid import uuid4 + canonical.write_pretty(receipts / (uuid4().hex + ".json"), outcome.execution_receipt) if outcome.ok: cached = f", {outcome.conditional_hits} unchanged" if outcome.conditional_hits else "" print(f"[ok] {outcome.locator}: {outcome.comparison_status} bundle {outcome.bundle_id[:12]} ({outcome.requests} requests{cached})") @@ -225,6 +237,13 @@ def cmd_build(args: argparse.Namespace) -> int: print(f"error: {exc}", file=sys.stderr) return 1 print(f"bundle {bundle.bundle_id[:12]} ({bundle.manifest['comparison_status']}) written to {path}") + from uuid import uuid4 + from datetime import datetime, timezone + execution = bundle.execution_receipt + if not execution["run_id"]: + execution = dict(execution, run_id=uuid4().hex, started_at=datetime.now(timezone.utc).isoformat(), + ended_at=datetime.now(timezone.utc).isoformat()) + canonical.write_pretty(store.root / "executions" / (uuid4().hex + ".json"), execution) print(_bands_line(bundle.bands())) try: write_fleet_index(store) @@ -242,7 +261,7 @@ def cmd_verify(args: argparse.Namespace) -> int: print(f"FAIL {problem}") return 1 renderer = report_renderer(args.bundle) - if renderer == RENDERER_VERSION: + if renderer in ("devostasis.render.v4", "devostasis.render.v5"): print("verified: digests, identity preimage, persisted effective config and report reproducibility all match") else: # A historical renderer is not carried: the report is bound by its diff --git a/src/devostasis/compatibility.py b/src/devostasis/compatibility.py new file mode 100644 index 0000000..f7a0b7c --- /dev/null +++ b/src/devostasis/compatibility.py @@ -0,0 +1,139 @@ +"""Exact-token, operation-scoped compatibility (PV-COMPAT-001/002). + +Source validation always precedes compatibility. Production has no accepted +nonidentity edges; callers may install explicitly versioned local contracts. +Policies pin their entire finite edge set, so registry growth cannot change replay. +""" + +from copy import deepcopy +from dataclasses import dataclass + +from .canonical import canonical_bytes, digest + +CONTRACT = "devostasis.contract-compatibility.v1" +POLICY_CONTRACT = "devostasis.compatibility-dispatch-policy.v1" +SELECTION = "EXACT_UNIQUE_OR_AMBIGUOUS.v1" +OPERATIONS = frozenset({"READ", "VERIFY_CURRENT", "COMPARE", "PROJECT", "RENDER_INPUT"}) + + +class CompatibilityError(ValueError): + pass + + +def require(ok, message): + if not ok: + raise CompatibilityError(message) + + +def lineage(value): + require(isinstance(value, dict) and value and all(isinstance(k, str) and k and + isinstance(v, str) and v for k, v in value.items()), "LINEAGE_INVALID") + return digest(value) + + +@dataclass(frozen=True) +class Edge: + body: dict + body_digest: str + precondition: object + projection: object + + +class Registry: + def __init__(self): + self.validators, self.edges, self.policies = {}, {}, {} + + def register_lineage(self, ref, validator): + key = lineage(ref) + require(key not in self.validators, "LINEAGE_IDENTITY_COLLISION") + self.validators[key] = (deepcopy(ref), validator) + + def validate(self, ref, value): + key = lineage(ref) + require(key in self.validators, "LINEAGE_MISMATCH") + # Validate the source without a projection, current policy or target registry. + result = self.validators[key][1](deepcopy(value)) + require(result is True, "SOURCE_SCHEMA_INVALID") + + def register_edge(self, body, precondition, projection): + fields = {"edge_id", "edge_version", "source_lineage", "target_lineage", "operations", + "precondition_algorithm", "projection_algorithm", "unknown_field_policy", + "semantic_invariance", "comparison_compatible", "canonical_identity_effect"} + require(isinstance(body, dict) and set(body) == fields, "EDGE_SCHEMA_INVALID") + require(all(isinstance(body[k], str) and body[k] for k in + ("edge_id", "edge_version", "precondition_algorithm", "projection_algorithm")), "EDGE_SCHEMA_INVALID") + lineage(body["source_lineage"]); lineage(body["target_lineage"]) + require(body["source_lineage"] != body["target_lineage"], "EXACT_IDENTITY_CANNOT_BE_SHADOWED") + require(isinstance(body["operations"], list) and body["operations"] and + all(isinstance(op, str) and op in OPERATIONS for op in body["operations"]) and + len(set(body["operations"])) == len(body["operations"]), "EDGE_OPERATION_INVALID") + require(body["canonical_identity_effect"] == "NONE" and + type(body["semantic_invariance"]) is bool and type(body["comparison_compatible"]) is bool and + body["unknown_field_policy"] in ("REJECT", "EXPLICIT_EXTENSION"), "EDGE_SCHEMA_INVALID") + key = (body["edge_id"], body["edge_version"]) + require(key not in self.edges, "EDGE_IDENTITY_COLLISION") + self.edges[key] = Edge(deepcopy(body), digest(body), precondition, projection) + + def register_policy(self, policy_id, policy_version, eligible_edges): + require(isinstance(policy_id, str) and policy_id and isinstance(policy_version, str) and policy_version, + "POLICY_SCHEMA_INVALID") + require(isinstance(eligible_edges, list), "POLICY_SCHEMA_INVALID") + for ref in eligible_edges: + require(isinstance(ref, dict) and set(ref) == {"edge_id", "edge_version"} and + all(isinstance(v, str) and v for v in ref.values()), "POLICY_SCHEMA_INVALID") + ordered = sorted(deepcopy(eligible_edges), key=canonical_bytes) + require(len({digest(e) for e in ordered}) == len(ordered), "POLICY_DUPLICATE_EDGE") + body = {"policy_contract": POLICY_CONTRACT, "policy_id": policy_id, "policy_version": policy_version, + "eligible_edges": ordered, "selection_rule": SELECTION} + key = (policy_id, policy_version) + require(key not in self.policies or self.policies[key] == body, "POLICY_IDENTITY_COLLISION") + self.policies[key] = body + return {k: body[k] for k in ("policy_contract", "policy_id", "policy_version")} | {"policy_digest": digest(body)} + + def policy(self, ref): + require(isinstance(ref, dict) and set(ref) == {"policy_contract", "policy_id", "policy_version", "policy_digest"}, + "POLICY_REFERENCE_INVALID") + require(ref["policy_contract"] == POLICY_CONTRACT, "POLICY_REFERENCE_INVALID") + body = self.policies.get((ref["policy_id"], ref["policy_version"])) + require(body is not None and digest(body) == ref["policy_digest"], "RECORDED_POLICY_UNAVAILABLE_OR_CHANGED") + edges = [] + for item in body["eligible_edges"]: + edge = self.edges.get((item["edge_id"], item["edge_version"])) + require(edge is not None, "RECORDED_EDGE_UNAVAILABLE") + require(digest(edge.body) == edge.body_digest and all(edge.body[k] == item[k] for k in item), + "EDGE_IDENTITY_COLLISION") + edges.append(edge) + return edges + + def dispatch(self, source, target, operation, value, policy_ref=None): + self.validate(source, value) + require(operation in OPERATIONS, "OPERATION_UNSUPPORTED") + lineage(target) + provenance = {"contract": CONTRACT, "source_lineage": deepcopy(source), "target_lineage": deepcopy(target), + "operation": operation, "dispatch_policy": None, "selected_edge": None, + "result_lineage": deepcopy(target)} + if source == target: + return provenance | {"status": "EXACT_IDENTITY", "result": deepcopy(value)} + require(policy_ref is not None, "NO_DECLARED_COMPATIBILITY") + candidates = [] + for edge in self.policy(policy_ref): + b = edge.body + if b["source_lineage"] == source and b["target_lineage"] == target and operation in b["operations"]: + if operation == "COMPARE" and not (b["semantic_invariance"] and b["comparison_compatible"]): + continue + if edge.precondition(deepcopy(value)) is True: + candidates.append(edge) + require(candidates, "NO_DECLARED_COMPATIBILITY") + require(len(candidates) == 1, "AMBIGUOUS_COMPATIBILITY_EDGE") + edge = candidates[0] + result = edge.projection(deepcopy(value)) + self.validate(target, result) + provenance["dispatch_policy"] = deepcopy(policy_ref) + provenance["selected_edge"] = {k: edge.body[k] for k in ("edge_id", "edge_version")} + return provenance | {"status": "DECLARED_COMPATIBILITY", "result": result} + + def replay(self, record, value): + expected = self.dispatch(record["source_lineage"], record["target_lineage"], record["operation"], + value, record["dispatch_policy"]) + require(expected == record, "COMPATIBILITY_REPLAY_MISMATCH") + return expected diff --git a/src/devostasis/contracts.py b/src/devostasis/contracts.py index e1cd2c1..27b0115 100644 --- a/src/devostasis/contracts.py +++ b/src/devostasis/contracts.py @@ -28,8 +28,11 @@ # Bundle layout: PV-ARTIFACT-V1-005 (accepted by PV-REV-ARTIFACT-005) with the # gauges.json and demand.json members in the identity preimage (v2) and the # stored effective config as the semantic authority of verification (ART-23..25). -ARTIFACT_CONTRACT_VERSION = "devostasis.bundle.v2" -BUNDLE_IDENTITY_CONTRACT = "PV-BUNDLE-ID-002" +ARTIFACT_CONTRACT_VERSION = "devostasis.bundle.v3" +BUNDLE_IDENTITY_CONTRACT = "PV-BUNDLE-ID-003" +RECEIPT_IDENTITY_CONTRACT = "PV-RECEIPT-IDENTITY-003" +RECEIPT_IDENTITY_SCHEMA = "devostasis.receipt-identity.v1" +EXECUTION_RECEIPT_SCHEMA = "devostasis.execution-receipt.v1" EFFECTIVE_CONFIG_CONTRACT = "PV-EFFECTIVE-CONFIG-001" EFFECTIVE_CONFIG_AUTHORITY_CONTRACT = "PV-EFFECTIVE-CONFIG-AUTHORITY-001" EFFECTIVE_CONFIG_SCHEMA = "devostasis.effective-config.v2" @@ -47,7 +50,7 @@ # Deterministic Markdown renderer (v4 states the demand ordering of demand.v2 and # renders exact rational durations). -RENDERER_VERSION = "devostasis.render.v4" +RENDERER_VERSION = "devostasis.render.v5" # 0-100 gauges: accepted by PV-REV-GAUGE-001 as the versioned normalization for # presentation and same-Vital ordering; never compared across Vitals. @@ -69,8 +72,8 @@ SNAPSHOT_SCHEMA = "devostasis.snapshot.v1" DELTA_SCHEMA = "devostasis.delta.v2" ACTIVITY_SCHEMA = "devostasis.activity.v1" -MANIFEST_SCHEMA = "devostasis.manifest.v1" -OBSERVATIONS_SCHEMA = "devostasis.observations.v1" +MANIFEST_SCHEMA = "devostasis.manifest.v2" +OBSERVATIONS_SCHEMA = "devostasis.observations.v2" RECEIPT_SCHEMA = "devostasis.receipt.v2" # Canonical order of the seven core Vitals inside every snapshot. diff --git a/src/devostasis/lineages.py b/src/devostasis/lineages.py new file mode 100644 index 0000000..f26db6c --- /dev/null +++ b/src/devostasis/lineages.py @@ -0,0 +1,55 @@ +"""Finite public bundle lineage tuples. Tokens are opaque, never ordered.""" + +from .canonical import loads + +# artifact, identity, manifest, renderer, receipt, observations, config, delta, demand +BUNDLE_LINEAGES = frozenset({ + ("devostasis.bundle.v1", identity, "devostasis.manifest.v1", renderer, "devostasis.receipt.v1", + "devostasis.observations.v1", "devostasis.effective-config.v1", "devostasis.delta.v1", None) + for identity in ("PV-BUNDLE-ID-001", "PV-BUNDLE-ID-002") + for renderer in ("devostasis.render.v1", "devostasis.render.v2") +} | { + ("devostasis.bundle.v2", "PV-BUNDLE-ID-002", "devostasis.manifest.v1", "devostasis.render.v3", + "devostasis.receipt.v1", "devostasis.observations.v1", "devostasis.effective-config.v2", "devostasis.delta.v1", "devostasis.demand.v1"), + ("devostasis.bundle.v2", "PV-BUNDLE-ID-002", "devostasis.manifest.v1", "devostasis.render.v4", + "devostasis.receipt.v1", "devostasis.observations.v1", "devostasis.effective-config.v2", "devostasis.delta.v1", "devostasis.demand.v2"), + ("devostasis.bundle.v2", "PV-BUNDLE-ID-002", "devostasis.manifest.v1", "devostasis.render.v4", + "devostasis.receipt.v2", "devostasis.observations.v1", "devostasis.effective-config.v2", "devostasis.delta.v1", "devostasis.demand.v2"), + ("devostasis.bundle.v2", "PV-BUNDLE-ID-002", "devostasis.manifest.v1", "devostasis.render.v4", + "devostasis.receipt.v2", "devostasis.observations.v1", "devostasis.effective-config.v2", "devostasis.delta.v2", "devostasis.demand.v2"), + ("devostasis.bundle.v3", "PV-BUNDLE-ID-003", "devostasis.manifest.v2", "devostasis.render.v5", + "devostasis.receipt-identity.v1", "devostasis.observations.v2", "devostasis.effective-config.v2", "devostasis.delta.v2", "devostasis.demand.v2"), +}) + + +def validate_bundle_lineage(manifest, members): + """Admit a whole tuple, including persisted member schemas, before replay.""" + def schema(name, fallback=None): + if name not in members: + return fallback + value = loads(members[name].decode("utf-8")) + return value.get("schema") if isinstance(value, dict) else None + try: + successor = manifest.get("schema") == "devostasis.manifest.v2" + receipt = manifest.get("receipt_identity" if successor else "receipt") + observed_schema = manifest.get("observations_schema") if successor else "devostasis.observations.v1" + actual = (manifest.get("artifact_contract_version"), manifest.get("bundle_identity_contract"), manifest.get("schema"), + manifest.get("renderer_version"), receipt.get("schema") if isinstance(receipt, dict) else None, + schema("observations.json", observed_schema), schema("effective-config.json"), schema("delta.json"), + manifest.get("demand_contract")) + if actual not in BUNDLE_LINEAGES: + return ["LINEAGE_MISMATCH: undeclared bundle schema/contract tuple"] + required = {"vitals_contract_version": "PV-VITALS-V1-002", "observation_contract_version": "RAW-OBS-V0", + "ci_unit_contract_version": "PV-CI-UNIT-004", "policy_version": "devostasis.policy.v1", + "canonical_serialization_version": "devostasis.canon.v1", "effective_config_contract": "PV-EFFECTIVE-CONFIG-001"} + if any(manifest.get(k) != v for k, v in required.items()): + return ["LINEAGE_MISMATCH: unsupported required contract token"] + for name, expected in (("snapshot.json", "devostasis.snapshot.v1"), ("activity.json", "devostasis.activity.v1"), + ("gauges.json", "devostasis.gauges.v1"), ("demand.json", actual[-1])): + if name in members and schema(name) != expected: + return ["LINEAGE_MISMATCH: " + name] + if "gauges.json" in members and manifest.get("gauge_contract") != "devostasis.gauge.v1": + return ["LINEAGE_MISMATCH: gauge contract"] + except (ValueError, TypeError, UnicodeError, AttributeError): + return ["LINEAGE_MISMATCH: unreadable versioned member"] + return [] diff --git a/src/devostasis/observations.py b/src/devostasis/observations.py index f9de8f2..e876020 100644 --- a/src/devostasis/observations.py +++ b/src/devostasis/observations.py @@ -13,6 +13,7 @@ from . import canonical from .contracts import OBSERVATION_CONTRACT_VERSION, OBSERVATIONS_SCHEMA, RECEIPT_SCHEMA +from .contracts import RECEIPT_IDENTITY_CONTRACT, RECEIPT_IDENTITY_SCHEMA, EXECUTION_RECEIPT_SCHEMA AVAILABLE = "AVAILABLE" PARTIAL = "PARTIAL" @@ -51,6 +52,15 @@ class ObservationError(ValueError): """Raised when an envelope violates the observation contract.""" +def failed_execution(target, started_at, ended_at, reason, counters=None): + """An invocation can fail before any admissible receipt identity exists.""" + from uuid import uuid4 + return {"schema": EXECUTION_RECEIPT_SCHEMA, "run_id": uuid4().hex, + "started_at": started_at, "ended_at": ended_at, "result_bundle_id": None, + "receipt_identity": None, "diagnostics": [reason], + "run_meta": {"target_requested": target, **(counters or {})}} + + @dataclass(frozen=True) class Observation: observation_id: str @@ -137,9 +147,9 @@ class Receipt: It records *what* was asked for and what came back, never *how* the answers were fetched. The number of HTTP calls a run needed is a property of the - client and its cache, not of the evidence, so it lives in the bundle's - post-identity ``run_meta``: enabling a conditional cache must not change the - identity of a bundle built from identical observations (v2). + client and its cache, not of the evidence. Successor bundles retain only + ``identity()``; invocation fields and counters live in the external + ``execution()`` receipt. ``to_dict()`` preserves the historical v2 shape. """ run_id: str @@ -152,6 +162,43 @@ class Receipt: per_key: dict[str, dict[str, str]] = field(default_factory=dict) capability_notes: list[str] = field(default_factory=list) config_hash: str | None = None + coverage: dict[str, Any] = field(default_factory=dict) + diagnostics: list[str] = field(default_factory=list) + + def identity(self, coverage: dict[str, Any] | None = None) -> dict[str, Any]: + """Material acquisition provenance; invocation mechanics have no path here.""" + result = { + "schema": RECEIPT_IDENTITY_SCHEMA, + "contract": RECEIPT_IDENTITY_CONTRACT, + "collector_version": self.collector_version, + "target": dict(self.target), + "requested_keys": sorted(set(self.requested_keys)), + "returned_keys": sorted(set(self.returned_keys)), + "per_key": {key: dict(self.per_key[key]) for key in sorted(self.per_key)}, + "capability_notes": sorted(set(material_capability(n) for n in self.capability_notes)), + "coverage": dict(self.coverage, **(coverage or {})), + "config_hash": self.config_hash, + } + validate_receipt_identity(result) + return result + + def execution(self, bundle_id: str | None = None, run_meta=None, receipt_identity=None) -> dict[str, Any]: + """Current invocation audit, returned outside immutable bundle storage.""" + return {"schema": EXECUTION_RECEIPT_SCHEMA, "run_id": self.run_id, + "started_at": self.started_at, "ended_at": self.ended_at, + "result_bundle_id": bundle_id, "diagnostics": list(self.diagnostics) + + [n for n in self.capability_notes if n.startswith("REQUEST_BUDGET_EXHAUSTED:")], + "run_meta": dict(run_meta or {}), "receipt_identity": receipt_identity or self.identity()} + + @classmethod + def from_identity(cls, data): + validate_receipt_identity(data) + return cls(run_id="", started_at="", ended_at="", + collector_version=data["collector_version"], target=dict(data["target"]), + requested_keys=list(data["requested_keys"]), returned_keys=list(data["returned_keys"]), + per_key={k: dict(v) for k, v in data["per_key"].items()}, + capability_notes=list(data["capability_notes"]), config_hash=data["config_hash"], + coverage=dict(data["coverage"])) def to_dict(self) -> dict[str, Any]: return { @@ -259,6 +306,13 @@ def finalize_receipt(self, receipt: Receipt) -> Receipt: self.receipt = receipt return receipt + def receipt_identity(self): + if self.receipt is None: + return None + coverage = {key: self._items[key].coverage for key in self.receipt.requested_keys + if key in self._items and self._items[key].coverage is not None} + return self.receipt.identity(coverage) + def to_dict(self) -> dict[str, Any]: return { "schema": OBSERVATIONS_SCHEMA, @@ -266,12 +320,24 @@ def to_dict(self) -> dict[str, Any]: "subject": self.subject, "observed_at": self.observed_at, "observations": [item.to_dict() for item in self.sorted()], - "receipt": self.receipt.to_dict() if self.receipt else None, + "receipt_identity": self.receipt_identity(), } @classmethod def from_dict(cls, data: dict[str, Any]) -> "ObservationSet": - receipt = Receipt.from_dict(data["receipt"]) if data.get("receipt") else None + if not isinstance(data, dict) or data.get("observation_contract_version") != OBSERVATION_CONTRACT_VERSION: + raise ObservationError("observations: unsupported observation contract") + schema = data.get("schema") + if schema == OBSERVATIONS_SCHEMA: + if "receipt" in data or "receipt_identity" not in data: + raise ObservationError("observations: mixed receipt lineage") + receipt = Receipt.from_identity(data["receipt_identity"]) if data["receipt_identity"] else None + elif schema == "devostasis.observations.v1": + if "receipt_identity" in data: + raise ObservationError("observations: mixed receipt lineage") + receipt = Receipt.from_dict(data["receipt"]) if data.get("receipt") else None + else: + raise ObservationError("observations: unsupported schema") return cls( subject=data.get("subject", {}), observed_at=data["observed_at"], @@ -290,6 +356,47 @@ def digest(self) -> str: return canonical.digest(self.to_dict()) +def material_capability(note): + """Preserve exhaustion as evidence; its numeric request limit is mechanics.""" + if not isinstance(note, str): + raise ObservationError("capability note must be a semantic string") + if note.startswith("REQUEST_BUDGET_EXHAUSTED:"): + if not note.split(":", 1)[1].isdigit(): + raise ObservationError("ambiguous request-budget capability note") + return "REQUEST_BUDGET_EXHAUSTED" + return note + + +def validate_receipt_identity(value): + required = {"schema", "contract", "collector_version", "target", "requested_keys", + "returned_keys", "per_key", "capability_notes", "coverage", "config_hash"} + def check(ok, message): + if not ok: + raise ObservationError("receipt_identity: " + message) + check(isinstance(value, dict) and set(value) == required, "missing or unknown fields") + check(value["schema"] == RECEIPT_IDENTITY_SCHEMA and value["contract"] == RECEIPT_IDENTITY_CONTRACT, + "unsupported lineage") + check(isinstance(value["collector_version"], str) and bool(value["collector_version"]), "collector version required") + check(isinstance(value["target"], dict), "target must be an object") + for key in ("requested_keys", "returned_keys", "capability_notes"): + entries = value[key] + check(isinstance(entries, list) and all(isinstance(x, str) and x for x in entries), key + " must be strings") + check(entries == sorted(set(entries)), key + " must be unique and sorted") + requested = set(value["requested_keys"]) + check(all(material_capability(n) == n for n in value["capability_notes"]), "invocation accounting in capability facts") + check(set(value["returned_keys"]) <= requested, "returned key was not requested") + check(isinstance(value["per_key"], dict) and set(value["per_key"]) <= requested, "per-key scope mismatch") + for meta in value["per_key"].values(): + check(isinstance(meta, dict) and set(meta) == {"status", "freshness"}, "invalid per-key fields") + check(isinstance(meta["status"], str) and meta["status"] in STATUSES and + isinstance(meta["freshness"], str) and meta["freshness"] in FRESHNESSES, "invalid acquisition state") + check(isinstance(value["coverage"], dict) and set(value["coverage"]) <= requested and + all(isinstance(x, dict) for x in value["coverage"].values()), "invalid coverage") + check(value["config_hash"] is None or isinstance(value["config_hash"], str), "invalid config hash") + canonical.canonical_bytes(value) + return value + + def subset_count_problem(item: Observation) -> str | None: """Why a PARTIAL count is not a proven observed-subset lower bound, or None when it is. diff --git a/src/devostasis/render.py b/src/devostasis/render.py index 9b2986b..d6c4d9c 100644 --- a/src/devostasis/render.py +++ b/src/devostasis/render.py @@ -204,7 +204,7 @@ def render_report( if "observability" in sections: lines.append("## Observability") lines.append("") - receipt = manifest.get("receipt") or {} + receipt = manifest.get("receipt_identity") or manifest.get("receipt") or {} non_available = [ (key, meta) for key, meta in sorted((receipt.get("per_key") or {}).items()) if meta.get("status") != "AVAILABLE" or meta.get("freshness") != "FRESH" diff --git a/src/devostasis/runner.py b/src/devostasis/runner.py index 86adfb5..ed39cc1 100644 --- a/src/devostasis/runner.py +++ b/src/devostasis/runner.py @@ -17,7 +17,7 @@ from .config import Config, ResolvedProject from .contracts import OBSERVATION_CONTRACT_VERSION, VITALS_CONTRACT_VERSION from .history import FilesystemHistoryStore, HistoryStoreError, _write_atomic -from .observations import ObservationSet +from .observations import ObservationSet, failed_execution from .policy import POLICY_VERSION from .vitals import build_snapshot, evaluate_all @@ -38,6 +38,7 @@ class RunOutcome: error: str | None = None requests: int = 0 conditional_hits: int = 0 + execution_receipt: dict[str, Any] | None = None def observe(project: ResolvedProject, client: GitHubClient, now: datetime) -> ObservationSet: @@ -212,7 +213,10 @@ def run_project( try: obs = observe(project, client, now) except CollectionError as exc: - return RunOutcome(project.locator, False, error=str(exc), requests=client.request_count, conditional_hits=client.conditional_hits) + execution = failed_execution(project.locator, timeutil.format_ts(started), timeutil.format_ts(timeutil.now_utc()), str(exc), + {"requests": client.request_count, "conditional_hits": client.conditional_hits}) + return RunOutcome(project.locator, False, error=str(exc), requests=client.request_count, + conditional_hits=client.conditional_hits, execution_receipt=execution) identity = obs.subject.get("immutable_project_id") if admitted is not None and identity not in (None, ""): key = f"{obs.subject.get('forge_instance') or 'github.com'}:{identity}" @@ -224,6 +228,7 @@ def run_project( error=f"{DUPLICATE_PROJECT_IDENTITY}: {project.locator} is repository {identity}, already observed in this run as {earlier}; one repository is observed once", requests=client.request_count, conditional_hits=client.conditional_hits, + execution_receipt=obs.receipt.execution(receipt_identity=obs.receipt_identity()), ) admitted[key] = project.locator try: @@ -240,7 +245,10 @@ def run_project( bundle = build_from_observations(project, obs, store, run_meta) path = store.commit(bundle) except (BundleError, HistoryStoreError) as exc: - return RunOutcome(project.locator, False, error=str(exc), requests=client.request_count, conditional_hits=client.conditional_hits) + execution = obs.receipt.execution(run_meta=run_meta, receipt_identity=obs.receipt_identity()) + execution["diagnostics"].append(str(exc)) + return RunOutcome(project.locator, False, error=str(exc), requests=client.request_count, + conditional_hits=client.conditional_hits, execution_receipt=execution) return RunOutcome( project.locator, True, @@ -250,6 +258,7 @@ def run_project( path=str(path), requests=client.request_count, conditional_hits=client.conditional_hits, + execution_receipt=bundle.execution_receipt, ) @@ -318,6 +327,7 @@ def run_all( continue transport = UrllibTransport(token, user_agent=user_agent or "devostasis/0.1 (+https://github.com/drevendev/devostasis)") client = GitHubClient(transport, budget=request_budget, cache=cache) + invocation_started = timeutil.format_ts(timeutil.now_utc()) try: outcomes.append(run_project(project, store, client, now, admitted=admitted)) except Exception as exc: # noqa: BLE001 - one project's data must not end the fleet run @@ -334,6 +344,9 @@ def run_all( error=f"{type(exc).__name__}: {exc}", requests=client.request_count, conditional_hits=client.conditional_hits, + execution_receipt=failed_execution(project.locator, invocation_started, + timeutil.format_ts(timeutil.now_utc()), f"{type(exc).__name__}: {exc}", + {"requests": client.request_count, "conditional_hits": client.conditional_hits}), ) ) # The fleet surfaces first: they are part of the result. The cache is an diff --git a/src/devostasis/workscope/__init__.py b/src/devostasis/workscope/__init__.py index 27b4339..a2cfa11 100644 --- a/src/devostasis/workscope/__init__.py +++ b/src/devostasis/workscope/__init__.py @@ -1,3 +1,5 @@ """Versioned, read-only Evidence to Action companion. No eighth Vital.""" -CONTRACT = "devostasis.work.v1" +CONTRACT = "devostasis.work.v2" +CONTRACTS = ("devostasis.work.v1", CONTRACT) +ENGINES = {"devostasis.work.v1": "devostasis.work-engine.v1", CONTRACT: "devostasis.work-engine.v2"} diff --git a/src/devostasis/workscope/binding.py b/src/devostasis/workscope/binding.py new file mode 100644 index 0000000..bcb034e --- /dev/null +++ b/src/devostasis/workscope/binding.py @@ -0,0 +1,74 @@ +"""Named mutable source resolution; an old SHA is never a fallback.""" + +import re +from urllib.parse import quote + +from .model import binding, identity, require, revision, text + + +def route(provider, locator): + require(isinstance(locator, str), "SOURCE_PROJECT_INVALID") + if provider == "github": + require(re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", locator) and + all(p not in (".", "..") for p in locator.split("/")), "SOURCE_PROJECT_INVALID") + return "/repos/" + locator + require(locator.isdigit(), "SOURCE_PROJECT_INVALID") + return "/projects/" + locator + + +def obj(client, url): + result, error = client.obj(url) + require(result is not None, "SOURCE_UNAVAILABLE:" + (error or "UNKNOWN")) + return result + + +def resolve(client, target, kind, ref=None, source_locator=None): + require(client.provider == target["provider"] and client.endpoint == target["endpoint"], "SOURCE_ENDPOINT_MISMATCH") + target_route = route(client.provider, target["locator"]) + target_meta = obj(client, target_route) + require(str(target_meta["id"]) == target["project_id"], "SOURCE_TARGET_PROJECT_CHANGED") + if kind == "DEFAULT_BRANCH": + ref = target_meta["default_branch"] + source_locator = target["locator"] + text(ref, "source ref") + if kind == "CHANGE": + require(ref.isdigit(), "SOURCE_CHANGE_INVALID") + change = obj(client, target_route + ("/pulls/" if client.provider == "github" else "/merge_requests/") + ref) + if client.provider == "github": + require(change["state"] == "open" and str(change["base"]["repo"]["id"]) == target["project_id"], + "SOURCE_CHANGE_CLOSED_OR_WRONG_PROJECT") + head = change["head"]; sha = head["sha"] + source_locator = head["repo"]["full_name"] + source_id = str(head["repo"]["id"]) + else: + require(change["state"] == "opened" and str(change["target_project_id"]) == target["project_id"], + "SOURCE_CHANGE_CLOSED_OR_WRONG_PROJECT") + source_locator = str(change["source_project_id"]) + source_id, sha = source_locator, change["sha"] + else: + require(kind in ("BRANCH", "DEFAULT_BRANCH"), "SOURCE_KIND_UNSUPPORTED") + source_locator = source_locator or target["locator"] + source_meta = obj(client, route(client.provider, source_locator)) + source_id = str(source_meta["id"]) + branch = obj(client, route(client.provider, source_locator) + + ("/branches/" if client.provider == "github" else "/repository/branches/") + quote(ref, safe="")) + sha = branch["commit"]["sha" if client.provider == "github" else "id"] + revision(sha) + source_meta = obj(client, route(client.provider, source_locator)) + require(str(source_meta["id"]) == source_id, "SOURCE_PROJECT_CHANGED") + commit = obj(client, route(client.provider, source_locator) + + ("/commits/" if client.provider == "github" else "/repository/commits/") + sha) + require(commit["sha" if client.provider == "github" else "id"] == sha, "SOURCE_COMMIT_NOT_IN_PROJECT") + result = {"contract": "devostasis.work-source.v1", "kind": kind, "project": identity(target), + "source_project": {**identity(target), "project_id": source_id}, "source_locator": source_locator, + "ref": ref, "revision": sha} + binding(result, {**target, "revision": sha}) + return result + + +def recheck(client, target): + recorded = target.get("source_binding") + require(recorded is not None, "SOURCE_BINDING_REQUIRED") + current = resolve(client, target, recorded["kind"], recorded["ref"], recorded["source_locator"]) + require(current == recorded, "SOURCE_MOVED_REBUILD_SCOPE") + return current diff --git a/src/devostasis/workscope/bundle.py b/src/devostasis/workscope/bundle.py index 09c4a8a..4d826a3 100644 --- a/src/devostasis/workscope/bundle.py +++ b/src/devostasis/workscope/bundle.py @@ -11,7 +11,7 @@ import tempfile from ..canonical import canonical_bytes, digest, digest_bytes, loads -from . import CONTRACT +from . import CONTRACT, CONTRACTS, ENGINES from .model import ROLES, ScopeError, fields, identity, instant, number, require from .planner import project @@ -42,7 +42,7 @@ def build(inv, config, evidence, at=None, anchor=None): isinstance(anchor["manifest_digest"], str) and re.fullmatch(r"sha256:[0-9a-f]{64}", anchor["manifest_digest"]), "invalid Vitals anchor") content = {name: canonical_bytes(value) for name, value in zip(MEMBERS, (inv, config, evidence, scope))} content["report.md"] = render(scope) - preimage = {"contract": CONTRACT, "kind": "bundle", "engine": "devostasis.work-engine.v1", + preimage = {"contract": inv["contract"], "kind": "bundle", "engine": ENGINES[inv["contract"]], "members": {k: digest_bytes(v) for k, v in content.items()}, "vitals_anchor": anchor} manifest = {**preimage, "bundle_id": digest(preimage)} content["manifest.json"] = canonical_bytes(manifest) @@ -69,7 +69,7 @@ def verify_content(content): require(content[name] == canonical_bytes(value), "bundle member is not canonical") m = decoded["manifest.json"] fields(m, ("contract", "kind", "engine", "members", "vitals_anchor", "bundle_id")) - require(m["contract"] == CONTRACT and m["kind"] == "bundle" and m["engine"] == "devostasis.work-engine.v1", + require(m["contract"] in CONTRACTS and m["kind"] == "bundle" and m["engine"] == ENGINES[m["contract"]], "unsupported bundle contract or replay engine") require(set(m["members"]) == set(MEMBERS), "manifest member set mismatch") require(all(digest_bytes(content[k]) == m["members"][k] for k in MEMBERS), "bundle member digest mismatch") @@ -158,7 +158,7 @@ def publish(store, content): else: promote = True if promote: - atomic(pointer, canonical_bytes({"contract": CONTRACT, "bundle_id": bundle_id, + atomic(pointer, canonical_bytes({"contract": decoded["manifest.json"]["contract"], "bundle_id": bundle_id, "manifest_digest": digest_bytes(content["manifest.json"]), "project": identity(scope["subject"])})) return dest @@ -168,7 +168,7 @@ def latest(root): require(pointer.is_file() and not pointer.is_symlink() and pointer.stat().st_size < 8192, "latest pointer unavailable") data = loads(pointer.read_text(encoding="utf-8")) fields(data, ("contract", "bundle_id", "manifest_digest", "project")) - require(data["contract"] == CONTRACT and isinstance(data["bundle_id"], str) and + require(data["contract"] in CONTRACTS and isinstance(data["bundle_id"], str) and len(data["bundle_id"]) == 71 and all(c in "0123456789abcdef" for c in data["bundle_id"][7:]) and data["bundle_id"].startswith("sha256:"), "invalid latest pointer") dest = Path(root) / "bundles" / data["bundle_id"][7:] diff --git a/src/devostasis/workscope/cli.py b/src/devostasis/workscope/cli.py index 1018d94..8339e57 100644 --- a/src/devostasis/workscope/cli.py +++ b/src/devostasis/workscope/cli.py @@ -5,6 +5,7 @@ import argparse from datetime import datetime, timezone import os +import shutil from pathlib import Path import sys @@ -42,7 +43,12 @@ def client(args, provider=None, endpoint=None): if provider == "github" and not token: from ..cli import resolve_token token, _ = resolve_token(None) - return Client(provider, endpoint, token, max_requests=args.max_requests, max_pages=args.max_pages, + transport = None + if provider == "gitlab" and not token and not args.token_env and shutil.which("glab"): + from .glab import GlabTransport + require(not args.cache, "glab credential bridge requires cache disabled; use token-env for partitioned caching") + transport = GlabTransport(endpoint) + return Client(provider, endpoint, token, transport=transport, max_requests=args.max_requests, max_pages=args.max_pages, seconds=args.seconds, cache=args.cache) @@ -68,7 +74,9 @@ def cmd(args): validate(evidence); emit(evidence, args.output); return 0 if args.operation in ("collect", "run"): inv = collect(client(args), args.repo, read_json(args.policy), args.at, - revision=args.revision, context=args.context) + revision=args.revision, context=args.context, source_ref=args.source_ref, + source_change=args.source_change, source_project=args.source_project, + change_refs=[] if args.registered_only else args.change) if args.operation == "collect": emit(inv, args.output); return 0 if args.operation in ("build", "run"): @@ -87,6 +95,20 @@ def cmd(args): emit({"bundle_id": manifest["bundle_id"], "path": str(dest.resolve())}); return 0 decoded = latest(args.latest) if args.latest else verify(args.bundle) scope = decoded["scope.json"] + if args.operation in ("handoff", "verify-handoff"): + from .handoff import prepare, verify_packet + at = args.at or clock() + expected = {"provider": args.provider, "endpoint": args.endpoint or + ("https://api.github.com" if args.provider == "github" else "https://gitlab.com/api/v4"), + "project_id": args.expected_project_id} + if args.operation == "handoff": + require(digest(read_json(args.policy)) == scope["inputs"]["policy"], "consumer policy digest mismatch") + packet = prepare(decoded, args.item, client(args), at, args.actor, args.policy_version, expected) + emit(packet, args.output) + else: + verify_packet(read_json(args.packet), decoded, at, args.actor, args.policy_version, expected) + emit({"status": "VERIFIED", "authority": scope["authority"]}, args.output) + return 0 if args.operation in ("verify", "replay"): emit({"status": "VERIFIED", "bundle_id": decoded["manifest.json"]["bundle_id"]}); return 0 if args.operation == "slice": @@ -104,12 +126,16 @@ def cmd(args): ("https://api.github.com" if args.provider == "github" else "https://gitlab.com/api/v4"), "project_id": args.expected_project_id} require(identity(scope["subject"]) == expected, "consumer expected project mismatch") - require(scope["subject"]["context"] != "CANDIDATE" or item["source"].startswith("change:") or args.inventory, + bound = scope["subject"].get("source_binding") + require(scope["subject"]["context"] != "CANDIDATE" or bound or item["source"].startswith("change:") or args.inventory, "candidate content recheck requires a freshly attested --inventory; no mutable source ref was recorded") current = read_json(args.inventory) if args.inventory else collect( client(args, scope["subject"]["provider"], scope["subject"]["endpoint"]), scope["subject"]["locator"], decoded["policy.json"], args.at, context=scope["subject"]["context"], - revision=scope["subject"]["revision"] if scope["subject"]["context"] == "CANDIDATE" else None, selected=item) + revision=scope["subject"]["revision"] if scope["subject"]["context"] == "CANDIDATE" else None, selected=item, + source_ref=bound["ref"] if bound and bound["kind"] == "BRANCH" else None, + source_change=bound["ref"] if bound and bound["kind"] == "CHANGE" else None, + source_project=bound["source_locator"] if bound and bound["kind"] == "BRANCH" else None) now = args.at or clock() recheck(decoded, current, now, actor=args.actor, policy_version=args.policy_version, expected_project=expected) refreshed = project(current, decoded["policy.json"], decoded["evidence.json"], now) @@ -130,7 +156,7 @@ def cmd(args): def add_parser(parser): work = parser.add_parser("work", help="Evidence to Action: bounded read-only work scopes") operations = work.add_subparsers(dest="operation", required=True) - for operation in ("policy", "import", "collect", "build", "run", "verify", "replay", "slice", "explain", "recheck"): + for operation in ("policy", "import", "collect", "build", "run", "verify", "replay", "slice", "explain", "recheck", "handoff", "verify-handoff"): p = operations.add_parser(operation) p.set_defaults(func=cmd) if operation == "policy": @@ -138,17 +164,17 @@ def add_parser(parser): if operation == "import": p.add_argument("--report", required=True); p.add_argument("--profile", required=True, choices=("sarif.v1", "junit.v1", "cobertura.v1", "performance.v1")) p.add_argument("--producer", required=True); p.add_argument("--producer-version", required=True) - if operation in ("policy", "import", "collect", "slice", "explain", "recheck"): + if operation in ("policy", "import", "collect", "slice", "explain", "recheck", "handoff", "verify-handoff"): p.add_argument("--output") if operation in ("import", "build", "recheck"): p.add_argument("--inventory", required=operation != "recheck") if operation in ("import", "build", "run"): p.add_argument("--evidence") - if operation in ("collect", "build", "run", "recheck"): + if operation in ("collect", "build", "run", "recheck", "handoff"): p.add_argument("--policy", required=True) if operation in ("build", "run"): p.add_argument("--store", required=True); p.add_argument("--vitals-bundle") - if operation in ("collect", "run", "recheck"): + if operation in ("collect", "run", "recheck", "handoff", "verify-handoff"): p.add_argument("--provider", choices=("github", "gitlab"), default="github") p.add_argument("--endpoint"); p.add_argument("--token-env"); p.add_argument("--cache") p.add_argument("--max-requests", type=int, default=100); p.add_argument("--max-pages", type=int, default=10) @@ -156,16 +182,25 @@ def add_parser(parser): if operation in ("collect", "run"): p.add_argument("--repo", required=True); p.add_argument("--revision") p.add_argument("--context", choices=("CANONICAL", "CANDIDATE"), default="CANONICAL") - if operation in ("verify", "replay", "slice", "explain", "recheck"): + source = p.add_mutually_exclusive_group() + source.add_argument("--source-ref", help="named candidate branch, resolved live") + source.add_argument("--source-change", help="candidate PR/MR number, including fork source") + p.add_argument("--source-project", help="candidate branch source project (fork locator or numeric GitLab ID)") + selection = p.add_mutually_exclusive_group() + selection.add_argument("--change", action="append", help="collect only these PR/MR numbers (repeatable); explicit selected inventory scope") + selection.add_argument("--registered-only", action="store_true", help="collect only policy-registered implementations; skip the open-change listing") + if operation in ("verify", "replay", "slice", "explain", "recheck", "handoff", "verify-handoff"): loc = p.add_mutually_exclusive_group(required=True) loc.add_argument("--bundle"); loc.add_argument("--latest") if operation == "slice": p.add_argument("--role", choices=("all", "reviewer", "developer", "researcher", "analyst"), default="all") p.add_argument("--limit", type=int, default=20); p.add_argument("--cursor") - if operation in ("explain", "recheck"): + if operation in ("explain", "recheck", "handoff"): p.add_argument("--item", required=True) - if operation == "recheck": + if operation in ("recheck", "handoff", "verify-handoff"): p.add_argument("--actor", required=True); p.add_argument("--policy-version", required=True) p.add_argument("--expected-project-id", required=True) - if operation in ("import", "collect", "build", "run", "slice", "recheck"): + if operation == "verify-handoff": + p.add_argument("--packet", required=True) + if operation in ("import", "collect", "build", "run", "slice", "recheck", "handoff", "verify-handoff"): p.add_argument("--at", help="explicit RFC3339 clock for fixtures/offline replay") diff --git a/src/devostasis/workscope/collect.py b/src/devostasis/workscope/collect.py index 9fba682..3f5bd89 100644 --- a/src/devostasis/workscope/collect.py +++ b/src/devostasis/workscope/collect.py @@ -206,6 +206,24 @@ def _gl_issue(i): "owners": _owners(i), "updated_at": timestamp(i["updated_at"])} +def _change_binding(client, raw): + if client.provider == "github": + target = (raw.get("base") or {}).get("repo") or {} + source = (raw.get("head") or {}).get("repo") or {} + target_id, source_id, locator = target.get("id"), source.get("id"), source.get("full_name") + head, ref = raw["head"]["sha"], str(raw["number"]) + else: + target_id, source_id = raw.get("target_project_id"), raw.get("source_project_id") + locator = str(source_id) if source_id is not None else None + head, ref = raw["sha"], str(raw["iid"]) + if target_id is None or source_id is None or not locator: + return {} + project = {"provider": client.provider, "endpoint": client.endpoint, "project_id": str(target_id)} + return {"source_binding": {"contract": "devostasis.work-source.v1", "kind": "CHANGE", "project": project, + "source_project": {**project, "project_id": str(source_id)}, "source_locator": locator, + "ref": ref, "revision": head}} + + def _gh_change(client, repo, num): route = f"/repos/{repo}/pulls/{num}" p, err = client.obj(route) @@ -261,7 +279,7 @@ def _gh_change(client, repo, num): reviews = unavailable(failure or "CHANGE_MOVED_DURING_COLLECTION") checks = unavailable(failure or "CHANGE_MOVED_DURING_COLLECTION") threads = unavailable(failure or "CHANGE_MOVED_DURING_COLLECTION") - return {"id": str(p["number"]), "title": p["title"], "state": "MERGED" if p.get("merged") else "OPEN" if p["state"] == "open" else "CLOSED", + return {**_change_binding(client, p), "id": str(p["number"]), "title": p["title"], "state": "MERGED" if p.get("merged") else "OPEN" if p["state"] == "open" else "CLOSED", "author": p["user"]["login"], "owners": _owners(p), "reviewers": sorted({r["login"] for r in p.get("requested_reviewers", [])}), "head": head, "base": p["base"]["sha"], "updated_at": timestamp(p["updated_at"]), "draft": p.get("draft"), @@ -311,7 +329,7 @@ def discussion(d): if final is None or final.get("sha") != head or final.get("updated_at") != p["updated_at"]: checks = unavailable(failure or "CHANGE_MOVED_DURING_COLLECTION") threads = unavailable(failure or "CHANGE_MOVED_DURING_COLLECTION") - return {"id": str(p["iid"]), "title": p["title"], "state": {"opened": "OPEN", "merged": "MERGED", "closed": "CLOSED", "locked": "OPEN"}[p["state"]], + return {**_change_binding(client, p), "id": str(p["iid"]), "title": p["title"], "state": {"opened": "OPEN", "merged": "MERGED", "closed": "CLOSED", "locked": "OPEN"}[p["state"]], "author": p["author"]["username"], "owners": _owners(p), "reviewers": sorted({r["username"] for r in p.get("reviewers", [])}), "head": head, "base": p["diff_refs"]["base_sha"], "updated_at": timestamp(p["updated_at"]), "draft": p.get("draft"), @@ -319,14 +337,19 @@ def discussion(d): "reviews": reviews, "checks": checks, "threads": threads, "files": files} -def collect(client, locator, config, at=None, revision=None, context="CANONICAL", selected=None): +def collect(client, locator, config, at=None, revision=None, context="CANONICAL", selected=None, + source_ref=None, source_change=None, source_project=None, change_refs=None): policy(config) - require(isinstance(locator, str) and (locator.isdigit() if client.provider == "gitlab" else + require(isinstance(locator, str) and ((locator.isdigit() or re.fullmatch(r"[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)+", locator) + and all(v not in (".", "..") for v in locator.split("/"))) if client.provider == "gitlab" else re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", locator) and all(v not in (".", "..") for v in locator.split("/"))), "invalid project locator") - project_route = f"/repos/{locator}" if client.provider == "github" else f"/projects/{locator}" + project_route = f"/repos/{locator}" if client.provider == "github" else f"/projects/{quote(locator, safe='')}" meta, failure = client.obj(project_route) require(meta is not None, failure or "PROJECT_IDENTITY_UNAVAILABLE") + if client.provider == "gitlab": + locator = str(meta["id"]) + project_route = f"/projects/{locator}" branch = meta["default_branch"] ref_route = project_route + ("/commits/" if client.provider == "github" else "/repository/commits/") + quote(branch, safe="") commit, failure = client.obj(ref_route) @@ -336,6 +359,19 @@ def collect(client, locator, config, at=None, revision=None, context="CANONICAL" project = {"provider": client.provider, "endpoint": client.endpoint, "project_id": str(meta["id"]), "locator": meta["full_name"] if client.provider == "github" else str(meta["id"]), "revision": revision or default_sha, "context": context} + if context == "CANONICAL": + require(source_ref is None and source_change is None and source_project is None, "canonical source must be default branch") + project["source_binding"] = {"contract": "devostasis.work-source.v1", "kind": "DEFAULT_BRANCH", + "project": identity(project), "source_project": identity(project), "source_locator": project["locator"], + "ref": branch, "revision": default_sha} + else: + from .binding import resolve + require((source_ref is None) != (source_change is None), "candidate requires exactly one named source ref or change") + bound = resolve(client, project, "CHANGE" if source_change is not None else "BRANCH", + source_change or source_ref, source_project) + require(revision is None or revision == bound["revision"], "candidate revision moved or does not exist in source project") + project["revision"] = bound["revision"] + project["source_binding"] = bound subject(project) change_fn, issue_fn = (_gh_change, _gh_issue) if client.provider == "github" else (_gl_change, _gl_issue) change_route = project_route + ("/pulls" if client.provider == "github" else "/merge_requests") @@ -345,6 +381,11 @@ def collect(client, locator, config, at=None, revision=None, context="CANONICAL" if selected.get("criterion") == criterion["id"]: refs += criterion["implementations"] changes = complete() + elif change_refs is not None: + require(isinstance(change_refs, list) and len(change_refs) <= 100 and + all(isinstance(r, str) and r.isdigit() for r in change_refs), "invalid bounded change selection") + refs = change_refs + [r for c in config["criteria"] for r in c["implementations"]] + changes = complete() else: changes = client.pages(change_route, {"state": "open" if client.provider == "github" else "opened", "scope": "all"} if client.provider == "gitlab" else {"state": "open"}) refs = [str(v["number"] if client.provider == "github" else v["iid"]) for v in changes["items"]] @@ -375,5 +416,5 @@ def collect(client, locator, config, at=None, revision=None, context="CANONICAL" now = timestamp(at or datetime.now(timezone.utc).isoformat()) result = {"contract": CONTRACT, "kind": "inventory", "subject": project, "observed_at": now, "changes": changes, "issues": issues, "receipts": client.receipts, - "selection": {"changes": "SELECTED" if selected is not None else "OPEN_AND_REGISTERED", "issues": sorted(issue_refs)}} + "selection": {"changes": "SELECTED" if selected is not None or change_refs is not None else "OPEN_AND_REGISTERED", "issues": sorted(issue_refs)}} return inventory(result) diff --git a/src/devostasis/workscope/evidence.py b/src/devostasis/workscope/evidence.py index 57de4b0..01edd2c 100644 --- a/src/devostasis/workscope/evidence.py +++ b/src/devostasis/workscope/evidence.py @@ -10,7 +10,7 @@ from xml.etree import ElementTree from ..canonical import digest, digest_bytes -from . import CONTRACT +from . import CONTRACT, CONTRACTS from .model import ScopeError, fields, identity, instant, number, path, require, revision, subject, text MAX_REPORT_BYTES = 2 * 1024 * 1024 @@ -132,7 +132,7 @@ def _performance(data, producer): def validate(value): fields(value, ("contract", "kind", "sources")) - require(value["contract"] == CONTRACT and value["kind"] == "evidence", "unsupported evidence contract") + require(value["contract"] in CONTRACTS and value["kind"] == "evidence", "unsupported evidence contract") require(isinstance(value["sources"], list) and len(value["sources"]) <= 100, "invalid evidence sources") seen = set() for item in value["sources"]: diff --git a/src/devostasis/workscope/glab.py b/src/devostasis/workscope/glab.py new file mode 100644 index 0000000..cd712c0 --- /dev/null +++ b/src/devostasis/workscope/glab.py @@ -0,0 +1,60 @@ +"""Read-only bridge to an existing glab login; credentials stay with glab.""" + +import subprocess +import threading +from urllib.parse import urlsplit + +from .model import ScopeError, require + + +class GlabTransport: + def __init__(self, endpoint, executable="glab"): + parsed = urlsplit(endpoint) + require(parsed.scheme == "https" and parsed.path == "/api/v4" and not parsed.username + and not parsed.query and not parsed.fragment, "glab requires an HTTPS /api/v4 endpoint") + self.endpoint, self.host, self.executable = endpoint, parsed.netloc, executable + + def request(self, method, url, headers, body, timeout): + from .collect import MAX_RESPONSE + require(method == "GET" and body is None and url.startswith(self.endpoint + "/"), + "glab bridge permits same-endpoint GET only") + endpoint = url[len(self.endpoint) + 1:] + args = [self.executable, "api", endpoint, "--hostname", self.host, "--method", "GET", "--include"] + if "If-None-Match" in headers: + args += ["--header", "If-None-Match: " + headers["If-None-Match"]] + chunks = [] + try: + process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + except OSError as exc: + raise ScopeError("GLAB_UNAVAILABLE") from exc + + def read(): + data = process.stdout.read(MAX_RESPONSE + 65537) + chunks.append(data) + if len(data) > MAX_RESPONSE + 65536: + process.kill() + + reader = threading.Thread(target=read, daemon=True) + reader.start() + try: + process.wait(timeout=timeout) + except subprocess.TimeoutExpired as exc: + process.kill(); process.wait(); reader.join(timeout=1) + raise ScopeError("GLAB_TIME_CAP") from exc + reader.join(timeout=1) + require(not reader.is_alive(), "GLAB_RESPONSE_UNAVAILABLE") + raw = b"".join(chunks) + require(len(raw) <= MAX_RESPONSE + 65536, "RESPONSE_BYTE_CAP") + raw = raw.replace(b"\r\n", b"\n") + head, separator, payload = raw.partition(b"\n\n") + require(separator and head.startswith(b"HTTP/"), "GLAB_AUTH_OR_RESPONSE_UNAVAILABLE") + lines = head.decode("ascii").splitlines() + try: + code = int(lines[0].split()[1]) + response_headers = {k.strip().lower(): v.strip() for k, v in + (line.split(":", 1) for line in lines[1:] if ":" in line)} + except (ValueError, IndexError) as exc: + raise ScopeError("GLAB_RESPONSE_UNAVAILABLE") from exc + require(len(payload) <= MAX_RESPONSE, "RESPONSE_BYTE_CAP") + return code, response_headers, payload diff --git a/src/devostasis/workscope/handoff.py b/src/devostasis/workscope/handoff.py new file mode 100644 index 0000000..34b1021 --- /dev/null +++ b/src/devostasis/workscope/handoff.py @@ -0,0 +1,149 @@ +"""Verified, bounded source packets for external consumers. No execution authority.""" + +import base64 +import binascii +import hashlib +from urllib.parse import quote + +from ..canonical import canonical_bytes, digest, digest_bytes +from .binding import recheck as recheck_binding, route +from .collect import collect +from .model import fields, identity, instant, path, require, revision +from .planner import project + +CONTRACT = "devostasis.work-handoff.v1" + + +def regular_blob(client, binding, filename): + """Prove a regular blob in the pinned tree; never dereference a symlink.""" + root = route(client.provider, binding["source_locator"]) + parts = filename.split("/") + if client.provider == "github": + tree = binding["revision"] + for index, name in enumerate(parts): + raw, _ = client.get(root + "/git/trees/" + tree) + require(isinstance(raw, dict) and raw.get("truncated") is False and isinstance(raw.get("tree"), list), "SOURCE_TREE_INCOMPLETE") + matches = [e for e in raw["tree"] if e.get("path") == name] + require(len(matches) == 1, "SOURCE_PATH_NOT_UNIQUE_OR_MISSING") + entry = matches[0]; revision(entry["sha"]) + if index + 1 == len(parts): + require(entry.get("type") == "blob" and entry.get("mode") in ("100644", "100755"), "SOURCE_LINK_REFUSED") + return entry["sha"] + require(entry.get("type") == "tree" and entry.get("mode") == "040000", "SOURCE_LINK_REFUSED") + tree = entry["sha"] + parent = "/".join(parts[:-1]) + found = [] + for page in range(1, client.max_pages + 1): + rows, headers = client.get(root + "/repository/tree", {"ref": binding["revision"], "path": parent, "per_page": 100, "page": page}) + require(isinstance(rows, list) and len(rows) <= 100 and all(isinstance(e, dict) for e in rows), "SOURCE_TREE_INVALID") + found += [e for e in rows if e.get("path") == filename] + if not headers.get("x-next-page") and len(rows) < 100: + break + else: + require(False, "SOURCE_TREE_PAGE_CAP") + require(len(found) == 1, "SOURCE_PATH_NOT_UNIQUE_OR_MISSING") + entry = found[0] + require(entry.get("type") == "blob" and entry.get("mode") in ("100644", "100755"), "SOURCE_LINK_REFUSED") + return revision(entry["id"]) + + +def file_bytes(client, binding, filename, remaining): + path(filename) + blob_id = regular_blob(client, binding, filename) + root = route(client.provider, binding["source_locator"]) + endpoint = root + ("/contents/" if client.provider == "github" else "/repository/files/") + quote(filename, safe="") + raw, _ = client.get(endpoint, {"ref": binding["revision"]}) + require(isinstance(raw, dict) and raw.get("encoding") == "base64", "SOURCE_FILE_UNAVAILABLE_OR_UNSUPPORTED") + require(raw.get("path" if client.provider == "github" else "file_path") == filename, "SOURCE_FILE_PATH_MISMATCH") + require(type(raw.get("size")) is int and 0 <= raw["size"] <= remaining, "READ_BYTE_BUDGET_EXCEEDED") + if client.provider == "github": + require(raw.get("type") == "file" and not raw.get("submodule_git_url") and not raw.get("target"), "SOURCE_LINK_REFUSED") + else: + require(raw.get("commit_id") == binding["revision"], "SOURCE_FILE_REVISION_MISMATCH") + try: + encoded = raw["content"] + require(isinstance(encoded, str) and len(encoded) <= (remaining + 2) // 3 * 4 + 8192, + "READ_BYTE_BUDGET_EXCEEDED") + data = base64.b64decode("".join(encoded.split()).encode("ascii"), validate=True) + except (ValueError, UnicodeError, binascii.Error, KeyError) as exc: + raise ValueError("SOURCE_FILE_ENCODING_INVALID") from exc + require(len(data) == raw["size"] and len(data) <= remaining, "SOURCE_FILE_SIZE_MISMATCH") + hasher = hashlib.sha1 if len(blob_id) == 40 else hashlib.sha256 + require(hasher(b"blob " + str(len(data)).encode("ascii") + b"\0" + data).hexdigest() == blob_id, + "SOURCE_BLOB_DIGEST_MISMATCH") + return data + + +def prepare(decoded, item_id, client, at, actor, policy_version, expected_project): + scope, config = decoded["scope.json"], decoded["policy.json"] + require(identity(scope["subject"]) == expected_project and client.endpoint == expected_project["endpoint"] and + client.provider == expected_project["provider"], "consumer expected project mismatch") + require(actor == config["actor"] and policy_version == config["version"], "consumer actor or policy version mismatch") + require(instant(scope["planned_at"]) <= instant(at) <= instant(scope["expires_at"]), "scope expired or clock predates scope") + selected = next((i for i in scope["items"] if i["id"] == item_id), None) + require(selected is not None and selected["eligibility"] == "READY", "selected item is not ready") + bound = selected.get("source_binding") + require(bound is not None, "SOURCE_BINDING_REQUIRED_REBUILD_SCOPE") + target = {**scope["subject"], "revision": bound["revision"], "source_binding": bound, + "context": "CANONICAL" if bound["kind"] == "DEFAULT_BRANCH" else "CANDIDATE"} + recheck_binding(client, target) + original = scope["subject"].get("source_binding") + require(original is not None, "SOURCE_BINDING_REQUIRED_REBUILD_SCOPE") + current = collect(client, scope["subject"]["locator"], config, at, + context=scope["subject"]["context"], selected=selected, + revision=scope["subject"]["revision"], + source_ref=original["ref"] if original["kind"] == "BRANCH" else None, + source_change=original["ref"] if original["kind"] == "CHANGE" else None, + source_project=original["source_locator"] if original["kind"] == "BRANCH" else None) + require(identity(current["subject"]) == expected_project, "consumer project changed") + refreshed = project(current, config, decoded["evidence.json"], at) + item = next((i for i in refreshed["items"] if i["id"] == item_id), None) + require(item is not None and item["eligibility"] == "READY" and all(item[k] == selected[k] for k in + ("revision", "implementation_owner", "action", "acceptance", "read_set", "source_binding")), + "ITEM_CHANGED_REBUILD_SCOPE") + read_set = item["read_set"] + require(len(read_set["paths"]) == read_set["total_paths"] <= read_set["budget"]["max_files"], "READ_FILE_BUDGET_EXCEEDED") + files, total = [], 0 + for filename in read_set["paths"]: + data = file_bytes(client, bound, filename, read_set["budget"]["max_bytes"] - total) + total += len(data) + files.append({"path": filename, "size": len(data), "digest": digest_bytes(data), + "base64": base64.b64encode(data).decode("ascii")}) + recheck_binding(client, target) + packet = {"contract": CONTRACT, "kind": "handoff", "scope_bundle_id": decoded["manifest.json"]["bundle_id"], + "scope_digest": digest(scope), "item": selected, "checked_at": at, + "source_binding": bound, "refreshed_inventory_digest": digest(current), + "files": files, "total_bytes": total, "authority": scope["authority"]} + return packet | {"packet_id": digest(packet)} + + +def verify_packet(packet, decoded, at, actor, policy_version, expected_project): + fields(packet, ("contract", "kind", "scope_bundle_id", "scope_digest", "item", "checked_at", "source_binding", + "refreshed_inventory_digest", "files", "total_bytes", "authority", "packet_id")) + require(packet["contract"] == CONTRACT and packet["kind"] == "handoff", "unsupported handoff contract") + require(packet["packet_id"] == digest({k: v for k, v in packet.items() if k != "packet_id"}), "handoff identity mismatch") + scope, config = decoded["scope.json"], decoded["policy.json"] + require(packet["scope_bundle_id"] == decoded["manifest.json"]["bundle_id"] and packet["scope_digest"] == digest(scope), + "handoff scope mismatch") + require(actor == config["actor"] and policy_version == config["version"] and identity(scope["subject"]) == expected_project, + "consumer binding mismatch") + require(instant(scope["planned_at"]) <= instant(packet["checked_at"]) <= instant(at) <= instant(scope["expires_at"]), + "handoff expired or clock mismatch") + require(packet["item"] in scope["items"] and packet["item"]["eligibility"] == "READY" and + packet["source_binding"] == packet["item"].get("source_binding") and packet["authority"] == scope["authority"], + "handoff item mismatch") + item = packet["item"]; read_set = item["read_set"] + require(isinstance(packet["files"], list) and [f["path"] for f in packet["files"]] == read_set["paths"] and + len(packet["files"]) == read_set["total_paths"] <= read_set["budget"]["max_files"], "handoff read set mismatch") + total = 0 + for file in packet["files"]: + fields(file, ("path", "size", "digest", "base64")); path(file["path"]) + require(isinstance(file["base64"], str) and len(file["base64"]) <= (read_set["budget"]["max_bytes"] + 2) // 3 * 4, + "handoff byte budget exceeded") + data = base64.b64decode(file["base64"], validate=True) + require(type(file["size"]) is int and len(data) == file["size"] and digest_bytes(data) == file["digest"], "handoff file digest mismatch") + total += len(data) + require(type(packet["total_bytes"]) is int and total == packet["total_bytes"] <= read_set["budget"]["max_bytes"], + "handoff byte budget exceeded") + canonical_bytes(packet) + return packet diff --git a/src/devostasis/workscope/model.py b/src/devostasis/workscope/model.py index 5bad60a..8eeddda 100644 --- a/src/devostasis/workscope/model.py +++ b/src/devostasis/workscope/model.py @@ -8,7 +8,7 @@ from urllib.parse import unquote, urlsplit from ..canonical import canonical_bytes, digest -from . import CONTRACT +from . import CONTRACT, CONTRACTS QUEUES = ("review", "finish_merge", "implement_issue", "research", "analyze_code") STATUSES = ("COMPLETE", "PARTIAL", "UNAVAILABLE") @@ -81,7 +81,7 @@ def revision(value): def subject(value): - fields(value, ("provider", "endpoint", "project_id", "locator", "revision", "context")) + fields(value, ("provider", "endpoint", "project_id", "locator", "revision", "context"), ("source_binding",)) require(value["provider"] in ("github", "gitlab"), "provider: unsupported") parsed = urlsplit(value["endpoint"]) require(parsed.scheme == "https" and parsed.hostname and not parsed.username and @@ -90,9 +90,29 @@ def subject(value): text(value["project_id"]); require(value["project_id"].isdigit(), "numeric immutable project id required") text(value["locator"]); revision(value["revision"]) require(value["context"] in ("CANONICAL", "CANDIDATE"), "context: unsupported") + if "source_binding" in value: + binding(value["source_binding"], value) return value +def binding(value, target): + fields(value, ("contract", "kind", "project", "source_project", "source_locator", "ref", "revision")) + require(value["contract"] == "devostasis.work-source.v1" and value["kind"] in + ("DEFAULT_BRANCH", "BRANCH", "CHANGE"), "source binding: unsupported lineage") + require(value["project"] == identity(target), "source binding: target project mismatch") + fields(value["source_project"], ("provider", "endpoint", "project_id")) + require(value["source_project"]["provider"] == target["provider"] and + value["source_project"]["endpoint"] == target["endpoint"] and + isinstance(value["source_project"]["project_id"], str) and + value["source_project"]["project_id"].isdigit(), "source binding: wrong source project") + text(value["source_locator"]); text(value["ref"]); revision(value["revision"]) + require(value["revision"] == target["revision"], "source binding: revision mismatch") + require((value["kind"] == "DEFAULT_BRANCH") == (target["context"] == "CANONICAL"), + "source binding: context mismatch") + if value["kind"] == "CHANGE": + require(value["ref"].isdigit(), "source binding: numeric change ref required") + + def identity(value): return {k: value[k] for k in ("provider", "endpoint", "project_id")} @@ -141,7 +161,7 @@ def thread(value): def change(value): fields(value, ("id", "title", "state", "author", "owners", "reviewers", "head", "base", "updated_at", - "draft", "conflict", "merge_train", "reviews", "checks", "threads", "files")) + "draft", "conflict", "merge_train", "reviews", "checks", "threads", "files"), ("source_binding",)) text(value["id"]); text(value["title"]); text(value["author"]) strings(value["owners"], "owners"); revision(value["head"]); revision(value["base"]) strings(value["reviewers"], "requested reviewers") @@ -155,7 +175,8 @@ def change(value): def inventory(value): fields(value, ("contract", "kind", "subject", "observed_at", "changes", "issues", "receipts", "selection")) - require(value["contract"] == CONTRACT and value["kind"] == "inventory", "inventory: unsupported contract") + require(value["contract"] in CONTRACTS and value["kind"] == "inventory", "inventory: unsupported contract") + require(value["contract"] != "devostasis.work.v1" or "source_binding" not in value["subject"], "legacy inventory cannot carry source binding") subject(value["subject"]); observed = instant(value["observed_at"]) collection(value["changes"], change); collection(value["issues"], issue) fields(value["selection"], ("changes", "issues")) @@ -165,6 +186,9 @@ def inventory(value): for obj in value["changes"]["items"] + value["issues"]["items"]: require(instant(obj["updated_at"]) <= observed, "object changed after observation") if "reviews" in obj: + if "source_binding" in obj: + require(value["contract"] != "devostasis.work.v1", "legacy change cannot carry source binding") + binding(obj["source_binding"], {**value["subject"], "context": "CANDIDATE", "revision": obj["head"]}) for entry in obj["reviews"]["items"] + obj["checks"]["items"]: require(instant(entry["at"]) <= observed, "evidence arrived after observation") canonical_bytes(value) @@ -175,7 +199,7 @@ def policy(value): fields(value, ("contract", "kind", "version", "actor", "roles", "ttl_seconds", "queue_order", "required_checks", "minimum_approvals", "allow_merge_train", "read_budget", "priorities", "criteria", "requests", "dispositions")) - require(value["contract"] == CONTRACT and value["kind"] == "policy", "policy: unsupported contract") + require(value["contract"] in CONTRACTS and value["kind"] == "policy", "policy: unsupported contract") text(value["version"]); text(value["actor"]) strings(value["roles"], "roles"); require(set(value["roles"]) <= ROLES.keys(), "unknown role") number(value["ttl_seconds"], 1, 86400); number(value["minimum_approvals"], 1, 100) diff --git a/src/devostasis/workscope/planner.py b/src/devostasis/workscope/planner.py index 0fe0bce..672f625 100644 --- a/src/devostasis/workscope/planner.py +++ b/src/devostasis/workscope/planner.py @@ -103,6 +103,9 @@ def add(queue, source, criterion, revision, title, owner, paths, symbols, accept "total_paths": len(set(paths))}, "acceptance": list(acceptance), "evidence": sorted(set(refs) | set(inputs.values()))} item["merge_gate"] = merge_gate + if inv["contract"] == CONTRACT: + item["source_binding"] = (changes[source.split(":", 1)[1]].get("source_binding") + if source.startswith("change:") else inv["subject"].get("source_binding")) item["fingerprint"] = digest(item) items.append(item) @@ -201,7 +204,7 @@ def add(queue, source, criterion, revision, title, owner, paths, symbols, accept rank = {"READY": 0, "BLOCKED": 1, "UNKNOWN": 2} items.sort(key=lambda i: (rank[i["eligibility"]], i["priority"]["rank"], not i["continuation"], config["queue_order"].index(i["queue"]), i["id"])) - return {"contract": CONTRACT, "kind": "scope", "subject": inv["subject"], "observed_at": inv["observed_at"], + return {"contract": inv["contract"], "kind": "scope", "subject": inv["subject"], "observed_at": inv["observed_at"], "planned_at": at, "expires_at": timestamp((instant(inv["observed_at"]) + timedelta(seconds=config["ttl_seconds"])).isoformat()), "policy_version": config["version"], "inputs": inputs, "items": items, "queues": {q: [i["id"] for i in items if i["queue"] == q] for q in QUEUES}, diff --git a/tests/test_bundle_history.py b/tests/test_bundle_history.py index bad60b8..c36d96b 100644 --- a/tests/test_bundle_history.py +++ b/tests/test_bundle_history.py @@ -596,7 +596,7 @@ def test_the_receipt_and_the_evidence_are_bound_to_the_identity(tmp_path): bundle = build_from_observations(_project(), _obs(), FilesystemHistoryStore(tmp_path)) forged = dict(bundle.members) manifest = json.loads(forged["manifest.json"]) - manifest["receipt"]["capability_notes"] = ["FORGED"] + manifest["receipt_identity"]["capability_notes"] = ["FORGED"] forged["manifest.json"] = canonical.pretty_json(manifest).encode("utf-8") problems = verify_members(_rehash(forged)) assert any(p.startswith("RECEIPT_DIGEST_MISMATCH") for p in problems) diff --git a/tests/test_cli.py b/tests/test_cli.py index 85a89a2..fbe36cd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -126,7 +126,7 @@ def test_verify_does_not_claim_a_replay_it_did_not_perform(tmp_path, capsys): manifest["renderer_version"] = manifest["identity_preimage"]["renderer_version"] = "devostasis.render.v3" manifest["bundle_id"] = canonical.sha256_hex(canonical.canonical_bytes(manifest["identity_preimage"])) (latest / "manifest.json").write_bytes(canonical.pretty_json(manifest).encode("utf-8")) - assert main(["verify", "--bundle", str(latest)]) == 0 + assert main(["verify", "--bundle", str(latest)]) == 1 out = capsys.readouterr().out - assert "not replayed" in out and "devostasis.render.v3" in out + assert "RENDERER_VERSION_NOT_IN_LINEAGE" in out assert "reproducibility all match" not in out diff --git a/tests/test_compatibility.py b/tests/test_compatibility.py new file mode 100644 index 0000000..b3add4a --- /dev/null +++ b/tests/test_compatibility.py @@ -0,0 +1,114 @@ +"""Exact source admission and immutable dispatch policy, including COMPAT-21/22.""" + +from copy import deepcopy + +import pytest + +from devostasis.compatibility import CompatibilityError, Registry + +A, B, C = ({"envelope": x, "payload": "payload.v1"} for x in ("envelope.v1", "envelope.v2", "envelope.v3")) + + +def registry(): + r = Registry() + for ref in (A, B, C): + r.register_lineage(ref, lambda value: isinstance(value, dict) and set(value) == {"count"} and type(value["count"]) is int) + return r + + +def edge(r, name="representation", source=A, target=B, precondition=lambda v: True, operations=None): + body = {"edge_id": name, "edge_version": "opaque-a", "source_lineage": source, "target_lineage": target, + "operations": operations or ["READ"], "precondition_algorithm": "local.test.v1", + "projection_algorithm": "local.identity.v1", "unknown_field_policy": "REJECT", + "semantic_invariance": True, "comparison_compatible": False, "canonical_identity_effect": "NONE"} + r.register_edge(body, precondition, deepcopy) + return {"edge_id": name, "edge_version": "opaque-a"} + + +def test_exact_identity_precedes_edges_but_not_source_validation(): + r = registry() + assert r.dispatch(A, A, "READ", {"count": 0}, {"invalid": "ignored"})["status"] == "EXACT_IDENTITY" + with pytest.raises(CompatibilityError, match="SOURCE_SCHEMA_INVALID"): + r.dispatch(A, A, "READ", {"count": 0, "unexpected": 7}) + + +@pytest.mark.parametrize("ref", [{"envelope": "envelope.v999", "payload": "payload.v1"}, + {"envelope": "envelope.v0", "payload": "payload.v1"}, + {"envelope": "envelope.v1.0", "payload": "payload.v1"}, + {"envelope": "envelope.v1", "payload": "payload.v2"}]) +def test_unknown_versions_and_mixed_tuples_never_use_shape_fallback(ref): + r = registry() + with pytest.raises(CompatibilityError, match="LINEAGE_MISMATCH"): + r.dispatch(ref, A, "READ", {"count": 0}) + + +def test_source_validation_precedes_projection_and_no_version_means_no_success(): + r = registry(); ref = edge(r); p = r.register_policy("local", "one", [ref]) + for value in ({"count": False}, {}, {"count": None}): + with pytest.raises(CompatibilityError, match="SOURCE_SCHEMA_INVALID"): + r.dispatch(A, B, "READ", value, p) + + +def test_compatibility_is_asymmetric_nontransitive_and_operation_scoped(): + r = registry(); a = edge(r); b = edge(r, "next", B, C); p = r.register_policy("local", "one", [a, b]) + assert r.dispatch(A, B, "READ", {"count": 0}, p)["result"] == {"count": 0} + for source, target, op in ((B, A, "READ"), (A, C, "READ"), (A, B, "COMPARE"), (A, B, "PROJECT")): + with pytest.raises(CompatibilityError, match="NO_DECLARED_COMPATIBILITY"): + r.dispatch(source, target, op, {"count": 0}, p) + + +@pytest.mark.parametrize("truth", [False, None, "UNKNOWN", 1]) +def test_only_literal_true_preconditions_admit_an_edge(truth): + r = registry(); ref = edge(r, precondition=lambda v: truth); p = r.register_policy("local", "one", [ref]) + with pytest.raises(CompatibilityError, match="NO_DECLARED_COMPATIBILITY"): + r.dispatch(A, B, "READ", {"count": 1}, p) + + +def test_compat_21_recorded_policy_pins_registry_growth_and_replay(): + r = registry(); ref = edge(r); p = r.register_policy("local", "one", [ref]) + record = r.dispatch(A, B, "READ", {"count": 7}, p) + other = edge(r, "newer equivalent") + r.register_policy("local", "two", [ref, other]) + assert r.replay(record, {"count": 7}) == record + for key in ("policy_digest", "policy_version"): + bad = deepcopy(record); bad["dispatch_policy"][key] = "unavailable" + with pytest.raises(CompatibilityError, match="RECORDED_POLICY_UNAVAILABLE_OR_CHANGED"): + r.replay(bad, {"count": 7}) + r.edges.pop((ref["edge_id"], ref["edge_version"])) + with pytest.raises(CompatibilityError, match="RECORDED_EDGE_UNAVAILABLE"): + r.replay(record, {"count": 7}) + + +def test_compat_22_ambiguous_edges_do_not_admit_byte_equal_outputs(): + r = registry(); a, b = edge(r), edge(r, "other") + p = r.register_policy("local", "one", [a, b]) + assert r.register_policy("local", "one", [b, a]) == p + with pytest.raises(CompatibilityError, match="AMBIGUOUS_COMPATIBILITY_EDGE"): + r.dispatch(A, B, "READ", {"count": 0}, p) + + +def test_immutable_registry_identities_cannot_be_replaced(): + r = registry(); a = edge(r); r.register_policy("local", "one", [a]) + with pytest.raises(CompatibilityError, match="EDGE_IDENTITY_COLLISION"): + edge(r) + with pytest.raises(CompatibilityError, match="POLICY_IDENTITY_COLLISION"): + r.register_policy("local", "one", []) + with pytest.raises(CompatibilityError, match="LINEAGE_IDENTITY_COLLISION"): + r.register_lineage(A, lambda value: True) + + +def test_changed_registered_edge_body_cannot_reuse_its_identity(): + r = registry(); a = edge(r); p = r.register_policy("local", "one", [a]) + record = r.dispatch(A, B, "READ", {"count": 1}, p) + r.edges[(a["edge_id"], a["edge_version"])].body["projection_algorithm"] = "changed.v2" + with pytest.raises(CompatibilityError, match="EDGE_IDENTITY_COLLISION"): + r.replay(record, {"count": 1}) + + +def test_replay_binds_result_lineage_selected_edge_and_exact_result(): + r = registry(); a = edge(r); p = r.register_policy("local", "one", [a]) + record = r.dispatch(A, B, "READ", {"count": 7}, p) + for key, value in (("result", {"count": 8}), ("result_lineage", C), ("selected_edge", a | {"edge_version": "latest"})): + bad = deepcopy(record); bad[key] = value + with pytest.raises(CompatibilityError, match="COMPATIBILITY_REPLAY_MISMATCH"): + r.replay(bad, {"count": 7}) diff --git a/tests/test_glab_bridge.py b/tests/test_glab_bridge.py new file mode 100644 index 0000000..147d318 --- /dev/null +++ b/tests/test_glab_bridge.py @@ -0,0 +1,64 @@ +"""Existing-login transport does not extract credentials or widen reads.""" +import io +import subprocess + +import pytest + +from devostasis.workscope.glab import GlabTransport +from devostasis.workscope.model import ScopeError + + +class Process: + def __init__(self, output, timeout=False): + self.stdout = io.BytesIO(output); self.timeout = timeout; self.killed = False + def wait(self, timeout=None): + if self.timeout and not self.killed: + raise subprocess.TimeoutExpired("glab", timeout) + return 0 + def kill(self): + self.killed = True + + +def install(monkeypatch, output, timeout=False): + from devostasis.workscope import glab + proc, calls = Process(output, timeout), [] + def popen(args, **kwargs): + calls.append((args, kwargs)); return proc + monkeypatch.setattr(glab.subprocess, "Popen", popen) + return proc, calls + + +def test_existing_glab_login_uses_only_host_bound_get_and_no_token_command(monkeypatch): + _, calls = install(monkeypatch, b'HTTP/2.0 200 OK\r\nX-Next-Page: 2\r\n\r\n{"id":123}\n') + bridge = GlabTransport("https://gitlab.example/api/v4") + code, headers, body = bridge.request("GET", "https://gitlab.example/api/v4/projects/123", {}, None, 1) + assert code == 200 and headers["x-next-page"] == "2" and b'"id":123' in body + args = calls[0][0] + assert args == ["glab", "api", "projects/123", "--hostname", "gitlab.example", "--method", "GET", "--include"] + assert calls[0][1]["stderr"] == subprocess.DEVNULL + + +@pytest.mark.parametrize("method,url,body", [("POST", "https://gitlab.example/api/v4/projects/123", b"{}"), + ("GET", "https://other.example/api/v4/projects/123", None), + ("GET", "http://gitlab.example/api/v4/projects/123", None)]) +def test_bridge_refuses_mutation_or_another_endpoint(monkeypatch, method, url, body): + _, calls = install(monkeypatch, b'') + with pytest.raises(ScopeError, match="same-endpoint GET"): + GlabTransport("https://gitlab.example/api/v4").request(method, url, {}, body, 1) + assert not calls + + +def test_bridge_auth_failure_is_closed_and_does_not_echo_diagnostics(monkeypatch): + install(monkeypatch, b'not authenticated') + with pytest.raises(ScopeError, match="GLAB_AUTH_OR_RESPONSE_UNAVAILABLE"): + GlabTransport("https://gitlab.example/api/v4").request("GET", "https://gitlab.example/api/v4/projects/123", {}, None, 1) + + +def test_bridge_kills_slow_or_oversized_processes(monkeypatch): + from devostasis.workscope.collect import MAX_RESPONSE + for output, timeout, code in ((b'', True, "GLAB_TIME_CAP"), + (b'x' * (MAX_RESPONSE + 65537), False, "RESPONSE_BYTE_CAP")): + proc, _ = install(monkeypatch, output, timeout) + with pytest.raises(ScopeError, match=code): + GlabTransport("https://gitlab.example/api/v4").request("GET", "https://gitlab.example/api/v4/projects/123", {}, None, 1) + assert proc.killed diff --git a/tests/test_rate_limits.py b/tests/test_rate_limits.py index fbf8999..bfaa345 100644 --- a/tests/test_rate_limits.py +++ b/tests/test_rate_limits.py @@ -197,12 +197,25 @@ class OneShot(FakeTransport): outcome = run_project(single_project("acme/widget", config_version="1"), FilesystemHistoryStore(tmp_path), client, NOW) assert outcome.ok manifest = json.loads((FilesystemHistoryStore(tmp_path).project_dir("github.com/acme/widget") / "latest" / "manifest.json").read_text("utf-8")) - run_meta = manifest["run_meta"] + assert "run_meta" not in manifest + run_meta = outcome.execution_receipt["run_meta"] assert run_meta["requests"] == client.request_count and run_meta["billed_requests"] == client.billed_count assert run_meta["conditional_hits"] == 0 and run_meta["retries"] == 0 assert "run_meta" not in manifest["identity_preimage"] +def test_failed_collection_retains_external_invocation_audit(tmp_path): + from devostasis.runner import run_project + routes = _routes(); routes[BASE] = (403, {}, {"message": "Forbidden"}) + client = GitHubClient(FakeTransport(routes)) + outcome = run_project(single_project("acme/widget"), FilesystemHistoryStore(tmp_path), client, NOW) + assert not outcome.ok and outcome.execution_receipt["run_id"] + assert outcome.execution_receipt["result_bundle_id"] is None + assert outcome.execution_receipt["receipt_identity"] is None + assert outcome.execution_receipt["diagnostics"] + assert not list(tmp_path.rglob("manifest.json")) + + def test_a_missing_or_corrupt_cache_costs_requests_never_correctness(tmp_path): corrupt = tmp_path / "etags.json" corrupt.write_text("{ not json", encoding="utf-8") diff --git a/tests/test_receipt_identity.py b/tests/test_receipt_identity.py new file mode 100644 index 0000000..ed84311 --- /dev/null +++ b/tests/test_receipt_identity.py @@ -0,0 +1,137 @@ +"""Accepted RECEIPT-ID-01..15,17..21; 16 remains rejected and unassigned.""" + +from copy import deepcopy +import json +from pathlib import Path + +import pytest + +from devostasis import canonical +from devostasis.bundle import verify_members, load_bundle_dir +from devostasis.config import single_project +from devostasis.history import FilesystemHistoryStore, ImmutabilityError +from devostasis.observations import Observation, Receipt +from devostasis.runner import build_from_observations +from helpers import full_inputs, obs_set + + +def build(tmp_path, obs=None, project=None, run_meta=None): + return build_from_observations(project or single_project("acme/widget", config_version="1"), + obs or full_inputs(obs_set()), FilesystemHistoryStore(tmp_path), run_meta) + + +@pytest.mark.parametrize("field,value", [("run_id", "another invocation"), ("started_at", "2026-10-06T10:00:00Z"), + ("ended_at", "2026-10-06T10:10:00Z")], ids=["RECEIPT-ID-01", "RECEIPT-ID-02", "RECEIPT-ID-03"]) +def test_execution_fields_have_no_canonical_path(tmp_path, field, value): + obs = full_inputs(obs_set()); first = build(tmp_path, obs) + setattr(obs.receipt, field, value); second = build(tmp_path, obs) + assert first.bundle_id == second.bundle_id and first.members == second.members + assert first.execution_receipt != second.execution_receipt + + +def test_receipt_id_04_14_17_19_run_mechanics_are_external(tmp_path): + obs = full_inputs(obs_set()); first = build(tmp_path, obs) + obs.receipt.run_id = "run2"; obs.receipt.started_at = "later"; obs.receipt.ended_at = "slower" + obs.receipt.diagnostics = ["cache hit, retry, host narration"] + second = build(tmp_path, obs, run_meta={"requests": 77, "retries": 2, "host": "another host"}) + assert first.bundle_id == second.bundle_id and first.members == second.members + assert second.execution_receipt["result_bundle_id"] == second.bundle_id + assert second.execution_receipt["run_meta"]["requests"] == 77 + assert "run_meta" not in second.manifest and "receipt" not in second.manifest + assert not any(b"run2" in content or b"slower" in content for content in second.members.values()) + assert verify_members(second.members) == [] + + +def test_request_budget_count_cannot_leak_through_material_capability_notes(tmp_path): + obs = full_inputs(obs_set()); obs.receipt.capability_notes = ["REQUEST_BUDGET_EXHAUSTED:3"] + first = build(tmp_path, obs) + obs.receipt.capability_notes = ["REQUEST_BUDGET_EXHAUSTED:30"] + second = build(tmp_path, obs) + assert first.members == second.members and first.bundle_id == second.bundle_id + assert first.execution_receipt != second.execution_receipt + assert second.manifest["receipt_identity"]["capability_notes"] == ["REQUEST_BUDGET_EXHAUSTED"] + + +def test_receipt_id_05_observation_time_stays_material(tmp_path): + a = full_inputs(obs_set()); b = deepcopy(a); b.observed_at = "2026-10-06T12:00:00Z" + assert build(tmp_path, a).bundle_id != build(tmp_path, b).bundle_id + + +@pytest.mark.parametrize("change", ["available", "unknown", "partial", "zero", "freshness", "capability", "version", "scope"], + ids=["RECEIPT-ID-06", "RECEIPT-ID-07", "RECEIPT-ID-08", "RECEIPT-ID-09", "RECEIPT-ID-10", + "RECEIPT-ID-11", "RECEIPT-ID-12", "RECEIPT-ID-13"]) +def test_material_acquisition_distinctions_change_identity(tmp_path, change): + obs = full_inputs(obs_set()) + # Compare partial/full, zero/unknown and stale/fresh at the same key; + # adding a previously absent key would prove only absence/presence. + if change in ("partial", "zero", "freshness"): + baseline = "UNKNOWN" if change == "zero" else "AVAILABLE" + obs.add(Observation("extra", baseline, "count", None if baseline == "UNKNOWN" else 0)) + obs.finalize_receipt(obs.receipt) + first = build(tmp_path, obs) + if change in ("available", "unknown", "partial", "zero", "freshness"): + status = {"unknown": "UNKNOWN", "partial": "PARTIAL"}.get(change, "AVAILABLE") + obs.replace(Observation("extra", status, "count", None if status == "UNKNOWN" else 0, + freshness="STALE" if change == "freshness" else "FRESH")) + obs.finalize_receipt(obs.receipt) + elif change == "capability": + obs.receipt.capability_notes = ["MATERIAL_CAPABILITY_UNAVAILABLE"] + elif change == "version": + obs.receipt.collector_version = "normalization.v2" + else: + obs.receipt.target["immutable_project_id"] = "different-project" + second = build(tmp_path, obs) + assert second.bundle_id != first.bundle_id + assert second.manifest["identity_preimage"]["source_receipts_digest"] != first.manifest["identity_preimage"]["source_receipts_digest"] + + +def test_receipt_id_15_18_historical_version_is_not_projected(tmp_path): + legacy = load_bundle_dir(Path(__file__).parents[1] / "examples/legacy/bundle-v2") + before = dict(legacy) + assert verify_members(legacy) == [] and before == legacy + successor = build(tmp_path) + assert verify_members(successor.members) == [] + for value in ("devostasis.manifest.v1", "devostasis.manifest.v999"): + broken = dict(successor.members); m = deepcopy(successor.manifest); m["schema"] = value + broken["manifest.json"] = canonical.pretty_json(m).encode() + assert any("LINEAGE_MISMATCH" in p for p in verify_members(broken)) + + +def test_receipt_id_20_exact_duplicate_put_preserves_collision_rule(tmp_path): + store = FilesystemHistoryStore(tmp_path); obs = full_inputs(obs_set()) + first = build(tmp_path, obs) + obs.receipt.run_id = "other"; second = build(tmp_path, obs) + dest = store.commit(first) + assert store.commit(second) == dest + assert load_bundle_dir(dest) == first.members + second.members["report.md"] += b"forged\n" + with pytest.raises(ImmutabilityError): + store.commit(second) + + +def test_receipt_id_21_receipt_is_durable_when_observations_disabled(tmp_path): + b = build(tmp_path, project=single_project("acme/widget", config_version="1", observations_member=False)) + assert "observations.json" not in b.members and b.manifest["receipt_identity"] + assert canonical.digest(b.manifest["receipt_identity"]) == b.manifest["identity_preimage"]["source_receipts_digest"] + assert verify_members(b.members) == [] + for mutate in (lambda m: m.pop("receipt_identity"), lambda m: m["receipt_identity"].update(schema="devostasis.receipt.v2"), + lambda m: m["receipt_identity"].update(run_id="leaked"), lambda m: m["receipt_identity"].update(coverage={"unknown": {}})): + m = deepcopy(b.manifest); mutate(m); content = dict(b.members); content["manifest.json"] = canonical.pretty_json(m).encode() + assert verify_members(content) + + +def test_successor_verification_rejects_same_id_noncanonical_manifest_variants(tmp_path): + b = build(tmp_path) + for key, value in (("invocation", "execution leak"), ("supersedes_bundle_id", "another id"), + ("project_key", "github.com/acme/alias")): + m = deepcopy(b.manifest); m[key] = value; content = dict(b.members) + content["manifest.json"] = canonical.pretty_json(m).encode() + assert any("MANIFEST_SCHEMA_INVALID" in p for p in verify_members(content)) + content = dict(b.members); content["snapshot.json"] += b"\n" + assert any("CANONICAL_BYTES_MISMATCH" in p for p in verify_members(content)) + + +def test_alias_input_cannot_change_unhashed_canonical_project_key(tmp_path): + first = build(tmp_path) + second = build(tmp_path, project=single_project("acme/alias", config_version="1")) + assert first.bundle_id == second.bundle_id and first.members == second.members diff --git a/tests/test_successor_schema.py b/tests/test_successor_schema.py new file mode 100644 index 0000000..f94a4cc --- /dev/null +++ b/tests/test_successor_schema.py @@ -0,0 +1,23 @@ +"""Public closed successor schemas agree with the runtime-written shapes.""" +import json +from pathlib import Path + +from devostasis.bundle import MANIFEST_V2_FIELDS, _PREIMAGE_V3 +from devostasis.observations import validate_receipt_identity + +ROOT = Path(__file__).parents[1] + + +def test_successor_public_schemas_bind_exact_runtime_shapes(): + manifest = json.loads((ROOT / "schemas/manifest.v2.schema.json").read_text()) + assert set(manifest["required"]) == set(manifest["properties"]) == MANIFEST_V2_FIELDS + assert manifest["additionalProperties"] is False + preimage = manifest["properties"]["identity_preimage"] + assert set(preimage["required"]) == set(preimage["properties"]) == set(_PREIMAGE_V3) + assert preimage["additionalProperties"] is False + example = json.loads((ROOT / "examples/sample-bundle/manifest.json").read_text()) + assert set(example) == MANIFEST_V2_FIELDS + assert validate_receipt_identity(example["receipt_identity"]) == example["receipt_identity"] + receipt = json.loads((ROOT / "schemas/receipt-identity.schema.json").read_text()) + assert set(receipt["required"]) == set(receipt["properties"]) == set(example["receipt_identity"]) + assert receipt["additionalProperties"] is False diff --git a/tests/test_work_handoff.py b/tests/test_work_handoff.py new file mode 100644 index 0000000..c44a534 --- /dev/null +++ b/tests/test_work_handoff.py @@ -0,0 +1,159 @@ +"""Provider-backed, bounded consumer packets and mutable-ref invalidation.""" + +import base64 +from copy import deepcopy +import hashlib +import json +from urllib.parse import parse_qs, unquote, urlsplit + +import pytest + +from devostasis.canonical import canonical_bytes, digest +from devostasis.workscope.binding import resolve, recheck +from devostasis.workscope.bundle import build, verify_content +from devostasis.workscope.collect import Client, collect +from devostasis.workscope.evidence import empty_evidence +from devostasis.workscope.handoff import prepare, verify_packet +from devostasis.workscope.model import ScopeError, default_policy, identity + +AT = "2026-10-06T12:00:00Z" +SHA = "a" * 40 +NEW = "b" * 40 +DATA = b"source bytes\n" +BLOB = hashlib.sha1(b"blob " + str(len(DATA)).encode() + b"\0" + DATA).hexdigest() + + +class Transport: + def __init__(self, mutate=None): + self.calls, self.branches, self.mutate = [], 0, mutate + + def request(self, method, url, headers, body, timeout): + assert method == "GET" and body is None + parsed = urlsplit(url); p = unquote(parsed.path); q = parse_qs(parsed.query) + self.calls.append((p, q)) + if p in ("/api/v4/projects/acme/widget", "/api/v4/projects/123"): + value = {"id": 123, "default_branch": "main"} + elif "/repository/branches/" in p: + self.branches += 1 + value = {"commit": {"id": NEW if self.mutate == "moved" and self.branches > 1 else SHA}} + if self.mutate == "deleted": + return 404, {}, b"{}" + elif "/repository/commits/" in p: + value = {"id": p.rsplit("/", 1)[1] if p.rsplit("/", 1)[1] in (SHA, NEW) else SHA} + if self.mutate == "wrong_commit": + value = {"id": NEW} + elif p.endswith("/repository/tree"): + value = [{"path": "src/app.py", "id": BLOB, "type": "blob", + "mode": "120000" if self.mutate == "symlink" else "100644"}] + elif "/repository/files/" in p: + value = {"file_path": "src/app.py", "encoding": "base64", "size": len(DATA), + "content": base64.b64encode(DATA if self.mutate != "wrong_bytes" else b"FORGED bytes\n").decode(), + "commit_id": NEW if self.mutate == "wrong_file_revision" else SHA} + else: + raise AssertionError(p) + return 200, {}, canonical_bytes(value) + + +def fixture(transport=None): + transport = transport or Transport() + client = Client("gitlab", "https://gitlab.example/api/v4", transport=transport) + policy = default_policy("executor") + policy["requests"] = [{"id": "qualification", "queue": "research", "question": "Qualify the declared source boundary.", + "paths": ["src/app.py"], "symbols": [], "acceptance": ["Return a pinned qualification result."], "dependencies": [], "owner": None}] + inv = collect(client, "acme/widget", policy, AT, change_refs=[]) + _, content = build(inv, policy, empty_evidence()) + decoded = verify_content(content) + return decoded, client, transport + + +def prepare_packet(decoded, client): + selected = next(i for i in decoded["scope.json"]["items"] if i["source"] == "request:qualification") + return prepare(decoded, selected["id"], client, AT, "executor", "example-1", identity(decoded["scope.json"]["subject"])) + + +def test_real_provider_path_to_bounded_offline_verified_packet(): + decoded, client, transport = fixture() + packet = prepare_packet(decoded, client) + assert packet["total_bytes"] == len(DATA) and len(packet["files"]) == 1 + assert base64.b64decode(packet["files"][0]["base64"]) == DATA + assert not any(p.endswith("/merge_requests") for p, _ in transport.calls) + assert all(q.get("ref", [SHA])[0] == SHA for _, q in transport.calls) + assert verify_packet(packet, decoded, AT, "executor", "example-1", identity(decoded["scope.json"]["subject"])) == packet + + +@pytest.mark.parametrize("failure,reason", [("moved", "SOURCE_MOVED"), ("deleted", "SOURCE_UNAVAILABLE"), + ("wrong_commit", "SOURCE_COMMIT_NOT_IN_PROJECT"), ("symlink", "SOURCE_LINK_REFUSED"), + ("wrong_bytes", "SOURCE_BLOB_DIGEST_MISMATCH"), ("wrong_file_revision", "SOURCE_FILE_REVISION_MISMATCH")]) +def test_source_races_links_and_wrong_evidence_never_produce_a_packet(failure, reason): + decoded, client, transport = fixture() + transport.mutate = failure + with pytest.raises((ScopeError, ValueError), match=reason): + prepare_packet(decoded, client) + + +def test_file_budget_is_checked_before_source_bytes_are_read(): + decoded, client, transport = fixture() + inv, policy = decoded["inventory.json"], decoded["policy.json"] + policy["read_budget"]["max_bytes"] = 1 + _, content = build(inv, policy, empty_evidence()); decoded = verify_content(content) + with pytest.raises(ScopeError, match="READ_BYTE_BUDGET_EXCEEDED"): + prepare_packet(decoded, client) + + +@pytest.mark.parametrize("key,value", [("packet_id", "sha256:" + "0" * 64), ("total_bytes", 999), + ("authority", "merge anything"), ("scope_bundle_id", "another scope")]) +def test_offline_handoff_verifier_rejects_tampering(key, value): + decoded, client, _ = fixture(); packet = prepare_packet(decoded, client) + packet[key] = value + if key != "packet_id": + packet["packet_id"] = digest({k: v for k, v in packet.items() if k != "packet_id"}) + with pytest.raises((ScopeError, ValueError)): + verify_packet(packet, decoded, AT, "executor", "example-1", identity(decoded["scope.json"]["subject"])) + + +def test_candidate_requires_named_source_and_validates_commit_membership(): + decoded, _, _ = fixture() + policy = decoded["policy.json"] + with pytest.raises(ScopeError, match="named source"): + collect(Client("gitlab", "https://gitlab.example/api/v4", transport=Transport()), "123", policy, AT, + context="CANDIDATE", revision=SHA, change_refs=[]) + i = collect(Client("gitlab", "https://gitlab.example/api/v4", transport=Transport()), "123", policy, AT, + context="CANDIDATE", source_ref="feature", revision=SHA, change_refs=[]) + assert i["subject"]["source_binding"]["kind"] == "BRANCH" + + +def test_legacy_work_example_still_replays(): + from pathlib import Path + from devostasis.workscope.bundle import verify + assert verify(Path(__file__).parents[1] / "examples/legacy/work-v1")["manifest.json"]["contract"] == "devostasis.work.v1" + + +@pytest.mark.parametrize("provider", ["github", "gitlab"]) +def test_fork_change_binding_moves_and_retains_immutable_source_project(provider): + from devostasis.workscope.binding import resolve, recheck + host = "https://api.github.com" if provider == "github" else "https://gitlab.example/api/v4" + target = {"provider": provider, "endpoint": host, "project_id": "123", "locator": "acme/widget" if provider == "github" else "123", + "context": "CANDIDATE", "revision": SHA} + class Fork: + moved = False + def request(self, method, url, headers, body, timeout): + p = urlsplit(url).path + if p.endswith("/pulls/7"): + value = {"state": "open", "base": {"repo": {"id": 123}}, + "head": {"sha": NEW if self.moved else SHA, "repo": {"id": 999, "full_name": "fork/widget"}}} + elif p.endswith("/merge_requests/7"): + value = {"state": "opened", "target_project_id": 123, "source_project_id": 999, + "sha": NEW if self.moved else SHA} + elif "/commits/" in p: + value = {"sha" if provider == "github" else "id": p.rsplit("/", 1)[1]} + else: + value = {"id": 999 if p.endswith("/999") or p.endswith("/fork/widget") else 123} + return 200, {}, canonical_bytes(value) + transport = Fork(); client = Client(provider, host, transport=transport) + bound = resolve(client, target, "CHANGE", "7") + assert bound["source_project"]["project_id"] == "999" and bound["project"]["project_id"] == "123" + target["source_binding"] = bound + assert recheck(client, target) == bound + transport.moved = True + with pytest.raises(ScopeError, match="SOURCE_MOVED"): + recheck(client, target) From 9fe263910e855f4a9dafaf975807776c9f14cdf5 Mon Sep 17 00:00:00 2001 From: Aleksei Bogun Date: Wed, 7 Oct 2026 11:51:24 +0300 Subject: [PATCH 18/33] Implement 0.5.0 consumer operations and verified history transfer --- .devostasis/debt.json | 23 +- .devostasis/targets.json | 10 + .github/workflows/ci.yml | 13 + .github/workflows/observe-self.yml | 4 +- .github/workflows/work-observe.yml | 2 +- .github/workflows/work-operations.yml | 74 ++++++ CHANGELOG.md | 33 +++ README.md | 16 +- ROADMAP.md | 50 +++- docs/deployment.md | 8 +- docs/external-adoption.md | 15 +- docs/spec/PROVENANCE.md | 1 + docs/spec/README.md | 1 + docs/spec/conformance.md | 15 ++ docs/spec/work-operations.md | 73 ++++++ docs/work-operations.md | 87 +++++++ docs/work-scopes.md | 11 +- examples/work/adoption.json | 1 + examples/work/github-observe.yml | 4 +- examples/work/gitlab-observe.yml | 2 +- examples/work/gitlab-operations.yml | 44 ++++ pyproject.toml | 2 +- schemas/work-adoption.schema.json | 155 ++++++++++++ schemas/work-history-archive.schema.json | 78 ++++++ schemas/work-invocation.schema.json | 110 ++++++++ schemas/work-result.schema.json | 109 ++++++++ scripts/make_work_examples.py | 3 + src/devostasis/__init__.py | 2 +- src/devostasis/workscope/archive.py | 150 +++++++++++ src/devostasis/workscope/bundle.py | 3 + src/devostasis/workscope/cli.py | 77 +++++- src/devostasis/workscope/collect.py | 71 ++++-- src/devostasis/workscope/glab.py | 2 +- src/devostasis/workscope/operations.py | 305 +++++++++++++++++++++++ tests/test_release_pins.py | 12 +- tests/test_work_operations.py | 299 ++++++++++++++++++++++ tests/test_work_parallel.py | 113 +++++++++ 37 files changed, 1923 insertions(+), 55 deletions(-) create mode 100644 .github/workflows/work-operations.yml create mode 100644 docs/spec/work-operations.md create mode 100644 docs/work-operations.md create mode 100644 examples/work/adoption.json create mode 100644 examples/work/gitlab-operations.yml create mode 100644 schemas/work-adoption.schema.json create mode 100644 schemas/work-history-archive.schema.json create mode 100644 schemas/work-invocation.schema.json create mode 100644 schemas/work-result.schema.json create mode 100644 src/devostasis/workscope/archive.py create mode 100644 src/devostasis/workscope/operations.py create mode 100644 tests/test_work_operations.py create mode 100644 tests/test_work_parallel.py diff --git a/.devostasis/debt.json b/.devostasis/debt.json index 44b86b3..0cf1690 100644 --- a/.devostasis/debt.json +++ b/.devostasis/debt.json @@ -5,10 +5,27 @@ { "id": "D-7", "title": "Live candidate content recheck needs an explicit mutable source-ref binding", - "state": "open", + "state": "closed", "opened": "2026-10-02", - "updated": "2026-10-02", - "cost": "Candidate subject identity binds an immutable commit but not a mutable branch/MR content-source ref. The CLI therefore requires a freshly attested inventory for candidate issue/research/analysis recheck; it cannot reuse a historical SHA as proof that a branch stayed current. Named source-ref support is tracked in https://github.com/drevendev/Devostasis/issues/60. Canonical default-branch tasks and candidate PR head recheck are unaffected." + "updated": "2026-10-07", + "closed": "2026-10-07", + "cost": "Closed in the pending 0.4/0.5 implementation by named branch/PR/MR bindings with immutable source-project ids, commit membership and before/after handoff live resolution (issue #60). Historic v1 offline replay is preserved; legacy scopes need a freshly bound generation for automatic source handoff. Deployment and upstream release remain separate gates." + }, + { + "id": "D-8", + "title": "Large-project full GitLab collection still has incomplete bridge responses", + "state": "open", + "opened": "2026-10-06", + "updated": "2026-10-07", + "cost": "Bounded shared-budget concurrency and one per-generation train inventory improve throughput, but the external full probe remained PARTIAL (25 detailed MRs, 163 requests, 165300 ms). Bridge/native response qualification and complete repeated generations remain issue #62; selected inventory must not be called the complete backlog." + }, + { + "id": "D-9", + "title": "Whole-project private history transfer needs incremental ranges at long-term scale", + "state": "open", + "opened": "2026-10-07", + "updated": "2026-10-07", + "cost": "0.5 exports/verifies a bounded whole-project archive (64 MiB default, up to 512 MiB, 2000 records/category). A long-lived busy store will exceed that cap; incremental/sharded exact-bound transfer with preserved references and resumable completeness is issue #67. Current behavior is an explicit cap failure, never silent pruning or a partial backup presented as full." }, { "id": "D-6", diff --git a/.devostasis/targets.json b/.devostasis/targets.json index b25e971..780f7be 100644 --- a/.devostasis/targets.json +++ b/.devostasis/targets.json @@ -52,6 +52,16 @@ "title": "Evidence to Action: verified deterministic consumer work scopes, five queues, findings and bounded handoff", "state": "closed" }, + { + "id": "E2", + "title": "Reproducible consumer handoff: accepted receipt identity/compatibility, mutable sources and pinned bounded packets", + "state": "closed" + }, + { + "id": "E3", + "title": "Consumer Operations: bound repeated observation, operational receipts/results and verified private history transfer", + "state": "closed" + }, { "id": "v0.3", "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)", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b8baca..c970db1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,3 +55,16 @@ jobs: echo "bundle ids: $a $b" test -n "$a" && test "$a" = "$b" devostasis verify --bundle /tmp/store-a/projects/github.com/acme/widget/latest + - name: Consumer operation receipts and portable history + run: | + devostasis work observe --binding examples/work/adoption.json --policy examples/work/policy.json --store /tmp/work-a --at 2026-10-02T12:00:00Z + devostasis work export-history --store /tmp/work-a --expected-project-id 123456 --output /tmp/work-history.zip + devostasis work verify-history --archive /tmp/work-history.zip --expected-project-id 123456 + devostasis work import-history --archive /tmp/work-history.zip --store /tmp/work-restored --expected-project-id 123456 + devostasis work audit --store /tmp/work-a --expected-project-id 123456 --at 2026-10-02T12:00:00Z > /tmp/audit-a.json + devostasis work audit --store /tmp/work-restored --expected-project-id 123456 --at 2026-10-02T12:00:00Z > /tmp/audit-b.json + python - <<'PY' + import json + a, b = (json.load(open(p)) for p in ('/tmp/audit-a.json', '/tmp/audit-b.json')) + assert a == b and a['invocations']['DISABLED'] == 1 + PY diff --git a/.github/workflows/observe-self.yml b/.github/workflows/observe-self.yml index 1f962b8..f663fa9 100644 --- a/.github/workflows/observe-self.yml +++ b/.github/workflows/observe-self.yml @@ -4,7 +4,7 @@ # # jobs: # vitals: -# uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.4.0 +# uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.5.0 # with: # debt-labels: "type:debt" # @@ -24,7 +24,7 @@ on: devostasis-ref: description: Git ref of Devostasis to install (tag, branch or commit) type: string - default: v0.4.0 + default: v0.5.0 planning-source: description: milestones, file or none type: string diff --git a/.github/workflows/work-observe.yml b/.github/workflows/work-observe.yml index 2889241..16a8dae 100644 --- a/.github/workflows/work-observe.yml +++ b/.github/workflows/work-observe.yml @@ -5,7 +5,7 @@ on: inputs: devostasis-ref: type: string - default: v0.4.0 + default: v0.5.0 policy-path: type: string default: .devostasis/work-policy.json diff --git a/.github/workflows/work-operations.yml b/.github/workflows/work-operations.yml new file mode 100644 index 0000000..545bb44 --- /dev/null +++ b/.github/workflows/work-operations.yml @@ -0,0 +1,74 @@ +name: Bound Devostasis consumer operations +on: + workflow_call: + inputs: + devostasis-ref: + type: string + default: v0.5.0 + binding-path: + type: string + default: .devostasis/work-adoption.json + policy-path: + type: string + default: .devostasis/work-policy.json + history-runner: + description: Protected Linux runner with Python, Git and a private durable volume + type: string + required: true + history-store: + type: string + default: /var/lib/devostasis-work +permissions: + contents: read + issues: read + pull-requests: read + checks: read +concurrency: + group: canonical-work-operations-${{ github.repository }} + cancel-in-progress: false +jobs: + observe: + if: ${{ !github.event.pull_request && github.ref_name == github.event.repository.default_branch && github.ref_protected }} + runs-on: [self-hosted, linux, "${{ inputs.history-runner }}"] + continue-on-error: true + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Install pinned engine + env: + DEVOSTASIS_REF: ${{ inputs.devostasis-ref }} + run: python -m pip install "git+https://github.com/drevendev/Devostasis.git@${DEVOSTASIS_REF}" + - name: Verify caller and observe + env: + DEVOSTASIS_GITHUB_TOKEN: ${{ github.token }} + BINDING: ${{ inputs.binding-path }} + POLICY: ${{ inputs.policy-path }} + HISTORY_STORE: ${{ inputs.history-store }} + EXPECTED_PROJECT: ${{ github.repository_id }} + EXPECTED_ENDPOINT: ${{ github.api_url }} + run: | + mkdir -p work-output + python - <<'PY' + import json, os + from devostasis.workscope.operations import binding + b = binding(json.load(open(os.environ['BINDING'])), json.load(open(os.environ['POLICY']))) + if b['project']['provider'] != 'github' or b['project']['endpoint'] != os.environ['EXPECTED_ENDPOINT'] or b['project']['project_id'] != os.environ['EXPECTED_PROJECT']: + raise ValueError('caller project binding mismatch') + PY + rc=0 + python -m devostasis work observe --binding "$BINDING" --policy "$POLICY" \ + --store "$HISTORY_STORE" --output work-output/result.json 2> work-output/failure.txt || rc=$? + python -m devostasis work audit --store "$HISTORY_STORE" --provider github \ + --endpoint "$EXPECTED_ENDPOINT" --expected-project-id "$EXPECTED_PROJECT" --output work-output/audit.json + python -m devostasis work export-history --store "$HISTORY_STORE" --provider github \ + --endpoint "$EXPECTED_ENDPOINT" --expected-project-id "$EXPECTED_PROJECT" --output work-output/history.zip + cat work-output/audit.json >> "$GITHUB_STEP_SUMMARY" + exit "$rc" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: devostasis-operations-${{ github.run_id }} + path: work-output/ + retention-days: 30 + if-no-files-found: warn diff --git a/CHANGELOG.md b/CHANGELOG.md index 34b3463..2c81042 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,39 @@ 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.5.0 (unreleased) + +Consumer Operations, target E3. + +- Explicit project/actor/policy digest/engine version/selection/budget binding, + disabled by default; immutable COMPLETE/PARTIAL/FAILED/DISABLED invocation + receipts outside canonical bundles. Wrong binding stops before network; + immutable project identity is checked before dependent collection. +- Parallel change reads (1..8 workers) share locked request/deadline limits; + per-thread HTTP openers, one GitLab train inventory per generation, + deterministic material receipt ordering and preserved per-object head checks. + Transport failures retain source paths for bounded recovery. +- Packet-bound caller results with exact original acceptance and append-only + evidence references. REPORTED_COMPLETE stays a caller assertion, not an + independent review, merge/deploy permission or automatic healing of evidence. +- Verified private history audit and deterministic bounded ZIP_STORED export, + offline admission and idempotent restore. Includes generations and operational + receipts/results; never extracts paths, rejects foreign/linked/duplicate/ + changed members, preserves candidates and monotonic latest. +- Protected GitHub/GitLab caller recipes, synthetic disabled binding, public + schemas and CI end-to-end archive restore checks. Existing core/work canonical + lineages and historical replay remain unchanged; no runtime dependency added. +- External shadow qualification reused existing glab authentication: 19 requests, + two pinned files / 33,030 bytes, packet-bound reported research result, disabled + control and restored history spanning two actual UTC dates. Full parallel probe + admitted 25 MRs in 163 requests / 165,300 ms but remained PARTIAL due to bridge + response failures; #62 stays open. Production scheduling and B7's sustained + calibration remain deployment gates, not inferred from these two dates. +- Reject password-only endpoint userinfo before network reads (#66). +- Reject substituted native PR/MR/issue identities during selected collection + and final change rechecks (#68); verify returned selected refs during history + admission and refuse nonzero network telemetry for disabled receipts. + ## 0.4.0 (unreleased) Reproducible consumer handoff, target E2. diff --git a/README.md b/README.md index edf44fa..3287e77 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ The name is *development* plus *homeostasis*: the goal is a stable, honest reading of where a project actually stands, so that people and autonomous development systems can react to it. -The latest published tag is **v0.1.9**. This checkout prepares **0.4.0**, -including the pending 0.2.0/0.3.0 increments; these are not released yet. +The latest published tag is **v0.1.9**. This checkout prepares **0.5.0**, +including the pending 0.2.0/0.3.0/0.4.0 increments; these are not released yet. Use the checkout installation below to try the new companion before its tag is published. See [ROADMAP.md](ROADMAP.md) for release gates and next steps. @@ -36,6 +36,12 @@ packets that enforce the task's file/byte budget. Existing glab authentication works for self-hosted GitLab without a new token. Historical bundles still verify. Start with the [external adoption guide](docs/external-adoption.md). +**0.5.0 Consumer Operations** adds an explicit project/policy/engine binding, +repeated canonical observation with immutable invocation receipts, parallel +collection under one shared budget, private history audit/export/restore and +packet-bound caller results. Start with the [operations guide](docs/work-operations.md). +Calendar days and evidence gaps stay visible; caller reports retain their own authority. + ```sh devostasis work policy --actor YOUR_LOGIN --output policy.json devostasis work run --repo OWNER/REPO --policy policy.json --store /private/work-history @@ -193,10 +199,10 @@ For this unreleased checkout: pip install -e . ``` -After the 0.4.0 release tag is published: +After the 0.5.0 release tag is published: ```bash -pip install git+https://github.com/drevendev/devostasis@v0.4.0 +pip install git+https://github.com/drevendev/devostasis@v0.5.0 ``` Observe one repository (a GitHub token is read from `DEVOSTASIS_GITHUB_TOKEN`, @@ -255,7 +261,7 @@ permissions: jobs: vitals: - uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.4.0 + uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.5.0 decide: needs: vitals runs-on: ubuntu-latest diff --git a/ROADMAP.md b/ROADMAP.md index 7bb4c44..d19523c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,34 @@ 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-06 +## Release state and next steps — 2026-10-07 + +**0.5.0 Consumer Operations (E3) is implemented on +`zendreven/release-0.5.0`, stacked on [0.4 PR #64](https://github.com/drevendev/Devostasis/pull/64).** +It provides explicit adopter project/policy/engine bindings, repeated canonical +observation and immutable invocation receipts, bounded parallel collection, +packet-bound caller outcomes, full private-history audit/export/restore and +protected GitHub/GitLab caller recipes. Existing canonical core/work lineages +and historical replay remain unchanged. See [work-operations.md](docs/work-operations.md). + +The selected outside GitLab consumer has two actual dated shadow generations +(2026-10-06/07), a verified bounded research result, disabled-caller negative +control and a restored portable history. The second selected path took 19 +requests for two files / 33,030 bytes. The full parallel probe returned 25 MRs +in 163 requests / 165,300 ms but remained PARTIAL because some bridge responses +were unavailable; #62 is still open. Missing records are explicit evidence +gaps, not an empty backlog. The local caller proof does not claim a protected +production runner, installed schedule or sustained B7 calibration. + +The next gates are review/release of the prepared increments, activation of +the reviewed pinned caller in the chosen private deployment, sustained calendar +history/independent consumer feedback, the remaining B1 exact vectors (#23), +and accepted Phase C carriers after their existing gates. Private fleet billing +(#32), GitLab exact-head approvals (#58), full collection qualification (#62), +upstream release-branch automation (#63) and incremental archive scale (#67) +remain explicit. E3 closes the implemented operations path, not those gates. + +The 0.4/0.3 readiness snapshots below are retained as historical context. **0.4.0 Reproducible consumer handoff (E2) is now implemented on `zendreven/release-0.4.0`.** It carries the pending 0.2/0.3 ancestry and adds @@ -204,17 +231,17 @@ statement; the fix belongs in this repository, not in the rule. | Item | Owner of the next step | Blocker | | --- | --- | --- | | B1 vectors | research process, then this repository | `PV-TEST-001` is being produced as `PV-TEST-VECTORS-00n` units; their findings are [#20](https://github.com/drevendev/Devostasis/issues/20), [#21](https://github.com/drevendev/Devostasis/issues/21), [#22](https://github.com/drevendev/Devostasis/issues/22) and [#23](https://github.com/drevendev/Devostasis/issues/23), and two of them need vector kinds this repository has not built | -| B7 first outside consumer | **the owner** | picking a repository; B5 has landed, so the consumer surface no longer moves | +| B7 first outside consumer | integrating owner and elapsed observation time | repository selected; two dated shadow generations and consumer result qualified, production caller/schedule and sustained history remain | | C1 GitLab adapter | **this repository, after B7 and the compatibility policy** | the requirements are accepted (`PV-GITLAB-003` by `PV-REV-GITLAB-003`, [#34](https://github.com/drevendev/Devostasis/issues/34)) | | C2 uncollected GitHub surfaces | this repository | each surface needs a contract decision first | | C3 Instruments | **this repository, after B7 and the compatibility policy** | the envelope, the carrier and four instruments are accepted ([#34](https://github.com/drevendev/Devostasis/issues/34)); the carrier moves the configuration and bundle contracts, which is why the policy comes first | | C4 register generators | this repository | none; low value until a second project uses registers | | Renderer themes | owner | needs an owner-selected vocabulary per band | | PyPI publication | owner | needs an owner decision that the API surface is stable | -| Compatibility policy | **this repository, then the owner** | `devostasis.contract-compatibility.v1` is accepted (`PV-COMPAT-002` by `PV-REV-COMPAT-002`, [#34](https://github.com/drevendev/Devostasis/issues/34)); adopting it is what makes the Phase C contract moves safe for an adopter | +| Compatibility policy | maintainer release/review | accepted PV-COMPAT-002 implementation is in pending 0.4/0.5 ancestry; no production nonidentity edge is declared | | `PV-CAL-004` predictive validity | elapsed time | needs calendar days of bundles, not more bundles (see the self-review) | | Judgements owed to us | delivered | all four, listed below; one of them is a required repair | -| 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 | +| Accepted judgements not yet adopted | this repository after existing reach gates | core Phase C GitLab/Instrument carriers and blocked executable conformance surface remain; compatibility and receipt identity are implemented in pending 0.4/0.5 ancestry; Vital repairs/B3 are in pending 0.2 ancestry | | 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; 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 | @@ -244,8 +271,8 @@ an owner decision: | `PV-INT-TOTALITY-001` | [#33](https://github.com/drevendev/Devostasis/issues/33) | adopted in 0.1.9 under the same Integrity rule version, in place of the unresolved superset the review of 2026-09-30 found could omit the band it emitted ([#48](https://github.com/drevendev/Devostasis/issues/48)); `INT-TOTAL-01..06` and `08..13` executable | | `PV-REV-DIRECTION-CLOSED-TARGET-001`, `PV-DEBT-PARTIAL-001`, `PV-HORIZON-PARTIAL-001`, `PV-DIRECTION-INCOMPLETE-001`, `PV-TEST-004` | [#33](https://github.com/drevendev/Devostasis/issues/33) | adopted in 0.2.0: `direction.bands.v2`, `horizon.bands.v2`, `debt.bands.v2`, one rule version each; `DIR-CLOSED-01..09`, `DIR-INCOMPLETE-01..16`, `HOR-PARTIAL-01..16`, `DEBT-PARTIAL-01..16` executable; T9 held by its generator over the nine former gap families | | `PV-REV-HIST-002` | [#34](https://github.com/drevendev/Devostasis/issues/34) | adopted in 0.2.0 as target B3: `integrity.bands.v1+ci-unit-004+hist-002`, HIST-01..20 executable except HIST-14, which has no accepted migration to execute | -| `PV-REV-GITLAB-003`, the instrument contracts, `PV-COMPAT-002`, `PV-CONFORMANCE-SURFACE-001`, `PV-RENDER-CLINICAL-001` | [#34](https://github.com/drevendev/Devostasis/issues/34) | **not adopted**: the Phase C contracts this roadmap called blocked by research; they are ours now, sequenced after B7 and the compatibility policy | -| `PV-REV-RECEIPT-IDENTITY-003` | [#19](https://github.com/drevendev/Devostasis/issues/19) | **not adopted**: the fifth identity move, a fresh receipt, observations and manifest lineage with historical verification dispatch; it needs the owner to want it, and the contract document to implement from | +| `PV-REV-GITLAB-003`, instrument contracts, `PV-CONFORMANCE-SURFACE-001`, `PV-RENDER-CLINICAL-001` | [#34](https://github.com/drevendev/Devostasis/issues/34) | core reach/conformance/theme work remains unadopted under its existing gates; compatibility is implemented since pending 0.4.0; clinical owner selection is not established | +| `PV-REV-RECEIPT-IDENTITY-003` | [#19](https://github.com/drevendev/Devostasis/issues/19) | implemented in pending 0.4/0.5 ancestry: bundle v3, manifest v2, observations v2 and receipt identity v1 with historical verification dispatch; maintainer review/release remains | One judgement is ours to ask for rather than to wait on: `ORDER-01..15` are this repository's enumeration of the rules the accepted ordering contract @@ -298,11 +325,12 @@ names. ### B7. First outside repository integrates self-observation — the owner's move -Last on purpose: B5 was the final change to the consumer surface, so an -adopter after it builds on something that will not move under them. One job in -one workflow, its own `GITHUB_TOKEN`, no secret. The point is not the number -of adopters but the first feedback from a consumer who did not write the -contract. The only remaining blocker is the owner choosing a repository. +The owner selected an outside GitLab repository. The 0.4/0.5 shadow pilots +qualified source handoff, an explicitly bound local caller, two actual dated +generations, a reported research result, disabled-caller control and portable +restore. The point remains independent consumer feedback over sustained time. +Protected production scheduling, private durable deployment and calibration +history are still open; the templates and two dates do not substitute for them. ### Also open from the reporting review diff --git a/docs/deployment.md b/docs/deployment.md index e12c6a4..9141395 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,9 +1,11 @@ # Deployment -For the 0.4.0 Evidence to Action companion, five queues, producer reports, +For the 0.5.0 Evidence to Action companion, five queues, producer reports, GitHub/GitLab CI and protected durable history, see [work-scopes.md](work-scopes.md). Its store and contract are separate from the Vital bundles described here. +For explicit caller bindings and verified private history transfer, see +[work-operations.md](work-operations.md). There are two ways to run Devostasis, and they complement each other: @@ -29,7 +31,7 @@ permissions: jobs: vitals: - uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.4.0 + uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.5.0 with: debt-labels: "type:debt" # optional: issue labels that mark debt items # planning-source: file # optional: targets register instead of milestones @@ -166,7 +168,7 @@ jobs: with: python-version: "3.12" - name: Install Devostasis - run: python -m pip install --quiet "git+https://github.com/drevendev/devostasis@v0.4.0" + run: python -m pip install --quiet "git+https://github.com/drevendev/devostasis@v0.5.0" - name: Observe every configured project id: run continue-on-error: true diff --git a/docs/external-adoption.md b/docs/external-adoption.md index e12d915..06b3e0a 100644 --- a/docs/external-adoption.md +++ b/docs/external-adoption.md @@ -1,7 +1,7 @@ # External adoption: shadow consumer qualification -0.4.0 is prepared, not tagged. Install this reviewed checkout with -`python -m pip install -e .`. After release, pin `v0.4.0` or its exact commit. +0.5.0 is prepared, not tagged. Install this reviewed checkout with +`python -m pip install -e .`. After release, pin `v0.5.0` or its exact commit. The public synthetic examples use work scope v2; frozen v1 bundles remain in `examples/legacy/work-v1`. Do not replace production intake just because a shadow packet verifies. @@ -53,6 +53,17 @@ in 18 requests. A previously merged and deleted branch returned HTTP 404 instead of being reused. Private evidence remains outside this public repository. The result established a read-only shadow path, with no production activation. +Second external qualification, 2026-10-07: the explicitly bound observer +created a fresh canonical generation, verified a packet for the same two +paths in 19 requests and retained a packet-bound caller research result. +Disabled binding performed no network/canonical write. History including the +actual 2026-10-06 generation and operational records exported and restored +with equal audits: two actual UTC dates, not a claim of sustained calibration. +The full parallel inventory probe admitted 25 MRs / 163 requests / 165,300 ms; +coverage stayed PARTIAL after bridge response failures. No missing MR is +treated as absent, no exact-head approval is invented, and #62 remains open. +See [work-operations.md](work-operations.md) for the repeatable caller path. + Remaining deployment acceptance: reviewed pinned release; existing credential capability qualification; private durable history/writer; scheduled repeated generations over real work; independent comparison with forge state; installed diff --git a/docs/spec/PROVENANCE.md b/docs/spec/PROVENANCE.md index 2cd4360..8443602 100644 --- a/docs/spec/PROVENANCE.md +++ b/docs/spec/PROVENANCE.md @@ -8,6 +8,7 @@ normative here. The process is domain-neutral and documented in the | Identifier used by the code | Source unit(s) | Status when implemented | | --- | --- | --- | +| `devostasis.work-adoption.v1`, `devostasis.work-invocation.v1`, `devostasis.work-result.v1`, `devostasis.work-history-archive.v1`, `devostasis.work-history-audit.v1` | implementation-owned E3 operational adoption, issues #62/#65 | local, introduced in 0.5.0; separate from immutable work/core canonical members and accepted Instrument/Vital semantics; caller reports grant no independent acceptance or execution authority | | `devostasis.bundle.v3`, `PV-BUNDLE-ID-003`, `PV-RECEIPT-IDENTITY-003`, `devostasis.receipt-identity.v1`, `devostasis.observations.v2`, `devostasis.manifest.v2`, `devostasis.execution-receipt.v1`, `devostasis.render.v5` | cumulative PV-RECEIPT-IDENTITY-001/002/003 | independently accepted by PV-REV-RECEIPT-IDENTITY-003; adopted in 0.4.0 with RECEIPT-ID-01..15,17..21. Literal devostasis schema tokens are local choices; rejected ID-16 is preserved, not reassigned | | `devostasis.contract-compatibility.v1`, `devostasis.compatibility-dispatch-policy.v1` | PV-COMPAT-001/002 | independently accepted by PV-REV-COMPAT-002; adopted in 0.4.0; no accepted production nonidentity edge or migration is declared | | `devostasis.work.v2`, `devostasis.work-engine.v2`, `devostasis.work-source.v1`, `devostasis.work-handoff.v1` | implementation-owned E2 consumer continuation of issues #54..56/#60/#61 | local, introduced in 0.4.0; v1 replay preserved; no core Instrument semantics or execution authority | diff --git a/docs/spec/README.md b/docs/spec/README.md index 14fb296..2dc9c41 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -23,6 +23,7 @@ conformance tests. | [receipt-identity.md](receipt-identity.md) | 0.4 canonical receipt split, byte invariance and historical dispatch | | [compatibility.md](compatibility.md) | Exact tuples, operation-scoped edges, immutable policy and replay | | [work-handoff.md](work-handoff.md) | Work v2 mutable sources, bounded pinned source packets and offline verification | +| [work-operations.md](work-operations.md) | Adoption binding, invocation audit, caller reports and private history transfer | | [PROVENANCE.md](PROVENANCE.md) | Contract identifiers and the research units they come from | Vocabulary used throughout: diff --git a/docs/spec/conformance.md b/docs/spec/conformance.md index be77dbe..2fb77fe 100644 --- a/docs/spec/conformance.md +++ b/docs/spec/conformance.md @@ -593,3 +593,18 @@ the outstanding research cases above or claim acceptance of a Vital rule. RECEIPT-ID-16 is rejected historical evidence, not reassigned. These additions do not close the outstanding T3/T6 or core bundle/store vector carrier gaps. + +## 0.5 operational regression evidence (implementation-owned) + +| Boundary | Proof | +| --- | --- | +| explicit binding and disabled caller | `test_bad_binding_refuses_network_and_publication`, `test_disabled_binding_records_negative_control_without_canonical_write`, `test_wrong_immutable_project_stops_before_inventory_and_records_failure` | +| bounded shared collection | `test_parallel_requests_cannot_overdraw_shared_quota_or_ignore_deadline`, `test_concurrent_change_reads_keep_exact_evidence_and_reuse_train_inventory`, `test_transport_failure_retains_bounded_source_recovery_path` | +| exact native selection | `test_wrong_native_change_identity_never_satisfies_selected_collection`, `test_wrong_native_issue_identity_never_satisfies_registered_criterion`, `test_rehashed_binding_cannot_claim_another_selected_change`, `test_disabled_receipt_cannot_claim_received_network_bytes` | +| portable history and actual days | `test_repeated_bound_generations_and_calendar_days_survive_transfer`, `test_backfill_and_candidates_never_move_latest_or_add_canonical_days` | +| hostile archives and collisions | `test_untrusted_archive_is_fully_refused_before_store_mutation`, `test_caps_existing_collisions_and_changed_history_fail_closed` | +| reported result authority | `test_packet_bound_result_remains_caller_report_after_expiry_and_restore`, `test_outcome_cannot_override_binding_acceptance_or_packet` | +| caller CLI | `test_operations_cli_provides_offline_binding_audit_transfer_and_outcome` | + +DEV-OPS regression evidence introduces no research case identifiers and does +not close B1's missing accepted exact fixtures or B7's sustained calibration. diff --git a/docs/spec/work-operations.md b/docs/spec/work-operations.md new file mode 100644 index 0000000..5d04500 --- /dev/null +++ b/docs/spec/work-operations.md @@ -0,0 +1,73 @@ +# Consumer operations (0.5.0) + +These are implementation-owned operational contracts. The canonical work v1/v2 +and core bundle lineages remain unchanged. No Instrument/Vital, compatibility +edge, band, threshold or aggregate semantics are introduced. + +`devostasis.work-adoption.v1` is a closed explicit binding: integration id, +enabled boolean, exact installed engine version, HTTPS provider/project/locator, +policy actor/version/canonical digest, selection mode and shared budgets. +REGISTERED/CHANGES declare selected coverage; OPEN_AND_REGISTERED attempts full +open-and-registered enumeration. Unknown fields/versions, omitted enablement, +wrong engine/policy and unqualified endpoints fail before client creation. +The collector verifies immutable project identity before dependent reads. +Disabled invocation performs no API reads or canonical publication. + +`devostasis.work-invocation.v1` is an immutable operational receipt outside +bundle members, under `operations//runs/.json`. +It records the complete binding/digest, invocation start/end, COMPLETE/PARTIAL/ +FAILED/DISABLED status, exact bundle/manifest references when present, reasons +and noncanonical request/returned-response-byte/worker telemetry. Unavailable +transport responses retain their failure reason; their unknown body bytes are +not measured by that returned-byte counter. PARTIAL remains positively +recorded missing evidence, not successful coverage or a failed collection. +Unexpected provider failures become FAILED; audit-file write errors remain +errors. Wrong preflight configuration is rejected without inventing a receipt. + +Change reads may use 1..8 workers. Request allocation is locked under one +request/deadline budget; each HTTP thread owns its opener. GitLab merge-train +inventory is shared within the generation; each MR still has its own final +head/update check. Result and material receipt ordering is deterministic. +Concurrency cannot promote missing/partial approval or verification evidence. +Initial/final change reads and registered issue reads must match the requested +native number/iid; substituted objects remain unavailable. Selected invocation +admission also binds returned changes to the explicit and policy-registered refs. +An exhausted shared budget can change which records returned, so that generation +is PARTIAL. Workers do not add independent quotas or extend the deadline. + +`devostasis.work-result.v1` retains the complete verified handoff packet, exact +scope id, caller actor/outcome, one reported acceptance row per original +criterion, evidence references, notes and reporting time. A REPORTED_COMPLETE +claim requires PASS plus an evidence reference for every criterion. A BLOCKED +or ABANDONED claim retains FAIL/UNKNOWN. Every claim remains CALLER_REPORT_ONLY; +references are recorded, not dereferenced or certified as independent proof. +Later reports append; they never rewrite earlier reports or heal evidence. +Reporting after scope expiry is allowed because reporting is not execution: +the packet is verified at its recorded checked_at, not asserted fresh now. +Neither a receipt nor a packet digest authenticates a human principal. + +`devostasis.work-history-archive.v1` is a deterministic ZIP_STORED transfer +containing canonical index.json, exactly the six named members per generation, +and selected-project invocation/result records. Names, regular-file attributes, +duplicates, sizes, digests, project, lineage/replay, latest binding, result +packets, invocation coverage and same-time collisions are admitted before store +writes. No archive path is extracted. Encrypted/compressed/linked/unknown members +are refused. Archives contain private source bytes when result packets do. +Store/output/archive paths refuse symbolic links and Windows junctions in ancestors. + +Import checks source and destination collisions before appending through the +ordinary publisher. Valid backfill/candidates preserve canonical latest; a +restored store recomputes the newest verified canonical pointer. A missing +source pointer is retained as unavailable in export; verified orphan generations +may be recovered by explicit import. Imports are idempotent and resumable after +I/O failure, not one transaction across all files. Coordinate other writers +during transfer. Default total cap is 64 MiB, configurable up to 512 MiB; at most +2,000 generations, 2,000 invocations and 2,000 caller results are admitted. + +`devostasis.work-history-audit.v1` verifies every selected-project generation, +invocation, result and pointer, then reports canonical/candidate counts, UTC +calendar-day count, first/last observation and largest gap, invocation statuses, +caller outcome counts, explicit latest coverage/gap count and latest +AVAILABLE/EXPIRED/FUTURE/EMPTY/LATEST_UNAVAILABLE. +Candidate or rapid duplicate runs cannot add calendar days. Counts are operational +facts, not a calibration judgement, score or claim that B7 is complete. diff --git a/docs/work-operations.md b/docs/work-operations.md new file mode 100644 index 0000000..f0d8e50 --- /dev/null +++ b/docs/work-operations.md @@ -0,0 +1,87 @@ +# Repeatable consumer operations + +0.5.0 is prepared for review, not tagged. Install this checkout with +`python -m pip install -e .`; after review/release pin the exact source commit +or published tag. Existing glab authentication works unchanged. Runtime remains +standard-library-only and read-only toward the forge. + +Commit an adopter-owned work policy, then generate a binding (disabled by default): + +```sh +devostasis work bind --provider gitlab --endpoint https://gitlab.example/api/v4 \ + --repo GROUP/PROJECT --expected-project-id NUMERIC_ID \ + --policy .devostasis/work-policy.json --integration-id daily-shadow \ + --output .devostasis/work-adoption.json +``` + +Review the immutable project id, policy digest, engine version, selection and +budgets. REGISTERED is the initial selection; choose OPEN_AND_REGISTERED for a +complete inventory attempt or CHANGES with explicit numeric refs. Set enabled +to true only for the intended caller. A policy edit requires regenerating the +binding digest; it cannot silently replace the caller's policy under one version. + +```sh +devostasis work observe --binding .devostasis/work-adoption.json \ + --policy .devostasis/work-policy.json --store /private/work-history +devostasis work audit --store /private/work-history --provider gitlab \ + --endpoint https://gitlab.example/api/v4 --expected-project-id NUMERIC_ID +``` + +The observer verifies engine/policy/project before publication, emits the bundle +path/id and an immutable invocation receipt, and preserves failed/partial runs. +Disabled binding records a DISABLED invocation with no network/canonical write; +missing/wrong binding is an error. A fresh generation does not overwrite an older +receipt. Scheduling belongs to the caller: use +[gitlab-operations.yml](../examples/work/gitlab-operations.yml) or the protected +[GitHub reusable workflow](../.github/workflows/work-operations.yml), with a +protected private durable volume and reviewed default-branch configuration. +Both remain observational jobs. Candidates use the existing isolated `work run`. +Templates do not install runners, create schedules or activate production. + +History transfer includes all admitted bundles, invocation receipts and reported +results for exactly one project. Keep archives in the same private permission +domain as their source history; CI artifact expiry is not a backup policy. + +```sh +devostasis work export-history --store /private/work-history --provider gitlab \ + --endpoint https://gitlab.example/api/v4 --expected-project-id NUMERIC_ID --output history.zip +devostasis work verify-history --archive history.zip --provider gitlab \ + --endpoint https://gitlab.example/api/v4 --expected-project-id NUMERIC_ID +devostasis work import-history --archive history.zip --store /private/restored-history \ + --provider gitlab --endpoint https://gitlab.example/api/v4 --expected-project-id NUMERIC_ID +``` + +The archive is deterministic, bounded and verified before publication. No paths +are extracted. Restore can be retried after interruption; existing newer latest +stays latest, and candidates stay candidates. Corrupt history, foreign projects, +same-time conflicts and changed same-id bytes are errors. Audit every restored +store and compare its report with the source at the same explicit `--at` time. +The default cap is 64 MiB (`--max-bytes` up to 512 MiB); fixed per-category record +caps are documented in [the contract](spec/work-operations.md). + +After a consumer acts under its own authority, report the original acceptance: + +```json +{"actor":"YOUR_LOGIN","outcome":"REPORTED_COMPLETE","notes":"Bounded qualification completed.", + "acceptance":[{"criterion":"EXACT ORIGINAL ACCEPTANCE TEXT","status":"PASS", + "evidence":["issue:123#qualification"]}]} +``` + +```sh +devostasis work record-result --bundle BUNDLE_DIRECTORY --packet packet.json \ + --result result.json --store /private/work-history --provider gitlab \ + --endpoint https://gitlab.example/api/v4 --expected-project-id NUMERIC_ID \ + --actor YOUR_LOGIN --policy-version YOUR_POLICY_VERSION +``` + +All criteria must appear in their original order, with evidence references for +PASS. BLOCKED/ABANDONED may record UNKNOWN/FAIL. The report retains the full packet +and a digest-bound append-only record. `work verify-result --record RECORD_PATH` +uses the same bundle/project/actor/policy arguments to check it offline. These +are caller reports; independent acceptance, claim/review/merge/deployment stay +in the consumer's existing process. Recheck a live source before action. + +Parallel collection uses one shared quota/deadline. A binding selects workers +1..8; direct `work collect/run --workers 4` also supports it. For a capped run, +reduce selection, raise an intentional budget or recover explicitly missing +evidence. A selected generation cannot be labelled the complete backlog. diff --git a/docs/work-scopes.md b/docs/work-scopes.md index 61a3b9f..084d2d4 100644 --- a/docs/work-scopes.md +++ b/docs/work-scopes.md @@ -1,6 +1,6 @@ -# Evidence to Action in 0.4.0 +# Evidence to Action in 0.5.0 -0.4.0 is prepared on the release branch and is not tagged yet. Install the +0.5.0 is prepared on the release branch and is not tagged yet. Install the checkout for the offline walkthrough; the pinned workflow examples become available after release. The published engine remains v0.1.9. @@ -143,9 +143,10 @@ store. Configure runner volumes/tags and read credentials in the adopter; the candidate runner has neither the volume nor writer credentials. The store is append-only; the current default SHA is checked before canonical collection. A project moved during collection is not relabelled as fresh. Infrastructure -replication remains separate from the read adapter. A private GitLab pilot is -owned by its integrating project; this release validates fake transport and -uses GitHub for its live pilot. +replication remains separate from the read adapter. The external GitLab shadow +pilot now exercises bound observation, result receipts and portable history; +see [work-operations.md](work-operations.md). Private production scheduling +and sustained calibration remain the integrating project's deployment gates. ## Validation record diff --git a/examples/work/adoption.json b/examples/work/adoption.json new file mode 100644 index 0000000..26dd285 --- /dev/null +++ b/examples/work/adoption.json @@ -0,0 +1 @@ +{"budget":{"max_pages":10,"max_requests":100,"seconds":120,"workers":4},"contract":"devostasis.work-adoption.v1","enabled":false,"engine_version":"0.5.0","integration_id":"widget-shadow-observer","policy":{"actor":"worker","digest":"sha256:271481e214a15c2b1ae112382fadd7019cb7da1d407946ecb115821df81ae8c9","version":"widget-1"},"project":{"endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github"},"selection":{"changes":[],"mode":"REGISTERED"}} \ No newline at end of file diff --git a/examples/work/github-observe.yml b/examples/work/github-observe.yml index 29540a8..6d2433c 100644 --- a/examples/work/github-observe.yml +++ b/examples/work/github-observe.yml @@ -1,4 +1,4 @@ -# Copy into an adopter's .github/workflows directory after v0.4.0 is released. +# Copy into an adopter's .github/workflows directory after v0.5.0 is released. name: Work scope on: push: @@ -14,7 +14,7 @@ permissions: checks: read jobs: observe: - uses: drevendev/Devostasis/.github/workflows/work-observe.yml@v0.4.0 + uses: drevendev/Devostasis/.github/workflows/work-observe.yml@v0.5.0 with: policy-path: .devostasis/work-policy.json # This observer is deliberately not a required merge check. Artifacts are diff --git a/examples/work/gitlab-observe.yml b/examples/work/gitlab-observe.yml index 397686f..1357e91 100644 --- a/examples/work/gitlab-observe.yml +++ b/examples/work/gitlab-observe.yml @@ -2,7 +2,7 @@ # The runner has a mounted private durable volume at WORK_HISTORY_ROOT. Only # protected canonical jobs can write it. It must not be inside the observed repo. variables: - DEVOSTASIS_REF: "v0.4.0" + DEVOSTASIS_REF: "v0.5.0" WORK_POLICY: ".devostasis/work-policy.json" .work-observer: diff --git a/examples/work/gitlab-operations.yml b/examples/work/gitlab-operations.yml new file mode 100644 index 0000000..c2f5bc9 --- /dev/null +++ b/examples/work/gitlab-operations.yml @@ -0,0 +1,44 @@ +# Protected Linux shell runner: Python >=3.12, Git and existing host-specific glab +# authentication, or the adopter's existing DEVOSTASIS_GITLAB_TOKEN environment. +# Mount WORK_HISTORY_ROOT as a private durable volume outside the checkout. +variables: + DEVOSTASIS_REF: "v0.5.0" + WORK_POLICY: ".devostasis/work-policy.json" + WORK_BINDING: ".devostasis/work-adoption.json" + +work-operations: + stage: .post + needs: [] + tags: [work-history-protected] + resource_group: devostasis-canonical-history + allow_failure: true + rules: + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_COMMIT_REF_PROTECTED == "true" && ($CI_PIPELINE_SOURCE == "schedule" || $CI_PIPELINE_SOURCE == "web")' + when: always + before_script: + - python -m pip install "git+https://github.com/drevendev/Devostasis.git@${DEVOSTASIS_REF}" + script: + - test -n "$WORK_HISTORY_ROOT" + - mkdir -p work-output + - | + python - <<'PY' + import json, os + from devostasis.workscope.operations import binding + b = binding(json.load(open(os.environ['WORK_BINDING'])), json.load(open(os.environ['WORK_POLICY']))) + if b['project']['provider'] != 'gitlab' or b['project']['endpoint'] != os.environ['CI_API_V4_URL'] or b['project']['project_id'] != os.environ['CI_PROJECT_ID']: + raise ValueError('caller project binding mismatch') + PY + rc=0 + python -m devostasis work observe --binding "$WORK_BINDING" --policy "$WORK_POLICY" \ + --store "$WORK_HISTORY_ROOT" --output work-output/result.json 2> work-output/failure.txt || rc=$? + python -m devostasis work audit --store "$WORK_HISTORY_ROOT" --provider gitlab \ + --endpoint "$CI_API_V4_URL" --expected-project-id "$CI_PROJECT_ID" --output work-output/audit.json + python -m devostasis work export-history --store "$WORK_HISTORY_ROOT" --provider gitlab \ + --endpoint "$CI_API_V4_URL" --expected-project-id "$CI_PROJECT_ID" --output work-output/history.zip + exit "$rc" + artifacts: + when: always + paths: [work-output/] + expire_in: 30 days +# Configure the six-hour schedule in the adopter; this include does not create it. +# Keep archive replication private and durable beyond the CI artifact lifetime. diff --git a/pyproject.toml b/pyproject.toml index dd563a4..b8f56a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "devostasis" -version = "0.4.0" +version = "0.5.0" description = "Deterministic, model-free vital signs for software repositories." readme = "README.md" license = "MIT" diff --git a/schemas/work-adoption.schema.json b/schemas/work-adoption.schema.json new file mode 100644 index 0000000..7d98434 --- /dev/null +++ b/schemas/work-adoption.schema.json @@ -0,0 +1,155 @@ +{ + "type": "object", + "required": [ + "contract", + "integration_id", + "enabled", + "engine_version", + "project", + "policy", + "selection", + "budget" + ], + "properties": { + "contract": { + "const": "devostasis.work-adoption.v1" + }, + "integration_id": { + "type": "string", + "minLength": 1, + "maxLength": 8192, + "pattern": "^[^\\u0000-\\u001f]+$" + }, + "enabled": { + "type": "boolean" + }, + "engine_version": { + "type": "string", + "minLength": 1, + "maxLength": 8192, + "pattern": "^[^\\u0000-\\u001f]+$" + }, + "project": { + "type": "object", + "required": [ + "provider", + "endpoint", + "project_id", + "locator" + ], + "properties": { + "provider": { + "enum": [ + "github", + "gitlab" + ] + }, + "endpoint": { + "type": "string", + "format": "uri", + "pattern": "^https://" + }, + "project_id": { + "type": "string", + "pattern": "^[0-9]+$" + }, + "locator": { + "type": "string", + "minLength": 1, + "maxLength": 8192, + "pattern": "^[^\\u0000-\\u001f]+$" + } + }, + "additionalProperties": false + }, + "policy": { + "type": "object", + "required": [ + "actor", + "version", + "digest" + ], + "properties": { + "actor": { + "type": "string", + "minLength": 1, + "maxLength": 8192, + "pattern": "^[^\\u0000-\\u001f]+$" + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 8192, + "pattern": "^[^\\u0000-\\u001f]+$" + }, + "digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + } + }, + "additionalProperties": false + }, + "selection": { + "type": "object", + "required": [ + "mode", + "changes" + ], + "properties": { + "mode": { + "enum": [ + "OPEN_AND_REGISTERED", + "REGISTERED", + "CHANGES" + ] + }, + "changes": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[0-9]+$" + }, + "maxItems": 100, + "uniqueItems": true + } + }, + "additionalProperties": false + }, + "budget": { + "type": "object", + "required": [ + "max_requests", + "max_pages", + "seconds", + "workers" + ], + "properties": { + "max_requests": { + "type": "integer", + "minimum": 1, + "maximum": 10000 + }, + "max_pages": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "seconds": { + "type": "integer", + "minimum": 1, + "maximum": 3600 + }, + "workers": { + "type": "integer", + "minimum": 1, + "maximum": 8 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/drevendev/devostasis/schemas/work-adoption.schema.json", + "title": "Explicit canonical consumer adoption binding" +} diff --git a/schemas/work-history-archive.schema.json b/schemas/work-history-archive.schema.json new file mode 100644 index 0000000..f71e872 --- /dev/null +++ b/schemas/work-history-archive.schema.json @@ -0,0 +1,78 @@ +{ + "type": "object", + "required": [ + "contract", + "project", + "latest_bundle_id", + "members" + ], + "properties": { + "contract": { + "const": "devostasis.work-history-archive.v1" + }, + "project": { + "type": "object", + "required": [ + "provider", + "endpoint", + "project_id" + ], + "properties": { + "provider": { + "enum": [ + "github", + "gitlab" + ] + }, + "endpoint": { + "type": "string", + "format": "uri", + "pattern": "^https://" + }, + "project_id": { + "type": "string", + "pattern": "^[0-9]+$" + } + }, + "additionalProperties": false + }, + "latest_bundle_id": { + "oneOf": [ + { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + { + "type": "null" + } + ] + }, + "members": { + "type": "object", + "maxProperties": 16000, + "additionalProperties": { + "type": "object", + "required": [ + "digest", + "size" + ], + "properties": { + "digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "size": { + "type": "integer", + "minimum": 0, + "maximum": 536870912 + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/drevendev/devostasis/schemas/work-history-archive.schema.json", + "title": "Deterministic private history archive index" +} diff --git a/schemas/work-invocation.schema.json b/schemas/work-invocation.schema.json new file mode 100644 index 0000000..1aeb317 --- /dev/null +++ b/schemas/work-invocation.schema.json @@ -0,0 +1,110 @@ +{ + "type": "object", + "required": [ + "contract", + "run_id", + "binding", + "binding_digest", + "started_at", + "finished_at", + "status", + "bundle_id", + "manifest_digest", + "reasons", + "telemetry" + ], + "properties": { + "contract": { + "const": "devostasis.work-invocation.v1" + }, + "run_id": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "binding": { + "$ref": "work-adoption.schema.json" + }, + "binding_digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "finished_at": { + "type": "string", + "format": "date-time" + }, + "status": { + "enum": [ + "COMPLETE", + "PARTIAL", + "FAILED", + "DISABLED" + ] + }, + "bundle_id": { + "oneOf": [ + { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + { + "type": "null" + } + ] + }, + "manifest_digest": { + "oneOf": [ + { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + { + "type": "null" + } + ] + }, + "reasons": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 8192, + "pattern": "^[^\\u0000-\\u001f]+$" + }, + "maxItems": 10000 + }, + "telemetry": { + "type": "object", + "required": [ + "requests", + "response_bytes", + "workers" + ], + "properties": { + "requests": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "response_bytes": { + "type": "integer", + "minimum": 0, + "maximum": 41943040000 + }, + "workers": { + "type": "integer", + "minimum": 1, + "maximum": 8 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/drevendev/devostasis/schemas/work-invocation.schema.json", + "title": "Noncanonical immutable observer invocation receipt" +} diff --git a/schemas/work-result.schema.json b/schemas/work-result.schema.json new file mode 100644 index 0000000..67cb5ff --- /dev/null +++ b/schemas/work-result.schema.json @@ -0,0 +1,109 @@ +{ + "type": "object", + "required": [ + "contract", + "result_id", + "scope_bundle_id", + "packet", + "claim", + "recorded_at", + "authority" + ], + "properties": { + "contract": { + "const": "devostasis.work-result.v1" + }, + "result_id": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "scope_bundle_id": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "packet": { + "type": "object", + "description": "Complete devostasis.work-handoff.v1; validated against the referenced scope by the runtime." + }, + "claim": { + "type": "object", + "required": [ + "actor", + "outcome", + "notes", + "acceptance" + ], + "properties": { + "actor": { + "type": "string", + "minLength": 1, + "maxLength": 8192, + "pattern": "^[^\\u0000-\\u001f]+$" + }, + "outcome": { + "enum": [ + "REPORTED_COMPLETE", + "BLOCKED", + "ABANDONED" + ] + }, + "notes": { + "type": "string", + "minLength": 1, + "maxLength": 8192, + "pattern": "^[^\\u0000-\\u001f]+$" + }, + "acceptance": { + "type": "array", + "items": { + "type": "object", + "required": [ + "criterion", + "status", + "evidence" + ], + "properties": { + "criterion": { + "type": "string", + "minLength": 1, + "maxLength": 8192, + "pattern": "^[^\\u0000-\\u001f]+$" + }, + "status": { + "enum": [ + "PASS", + "FAIL", + "UNKNOWN" + ] + }, + "evidence": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 8192, + "pattern": "^[^\\u0000-\\u001f]+$" + }, + "maxItems": 10000 + } + }, + "additionalProperties": false + }, + "maxItems": 10000 + } + }, + "additionalProperties": false + }, + "recorded_at": { + "type": "string", + "format": "date-time" + }, + "authority": { + "const": "CALLER_REPORT_ONLY; no independent acceptance, claim, merge or deployment authority" + } + }, + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/drevendev/devostasis/schemas/work-result.schema.json", + "title": "Packet-bound caller report; no independent acceptance" +} diff --git a/scripts/make_work_examples.py b/scripts/make_work_examples.py index 7da355a..54a7bd0 100644 --- a/scripts/make_work_examples.py +++ b/scripts/make_work_examples.py @@ -51,4 +51,7 @@ destination.mkdir(exist_ok=True) for name, value in content.items(): (destination / name).write_bytes(value) +from devostasis.workscope.operations import make_binding +(root / "adoption.json").write_bytes(canonical_bytes(make_binding( + {**identity, "locator": subject["locator"]}, policy, "widget-shadow-observer"))) print(destination) diff --git a/src/devostasis/__init__.py b/src/devostasis/__init__.py index 0b8c641..4862142 100644 --- a/src/devostasis/__init__.py +++ b/src/devostasis/__init__.py @@ -1,3 +1,3 @@ """Devostasis: deterministic, model-free vital signs for software repositories.""" -__version__ = "0.4.0" +__version__ = "0.5.0" diff --git a/src/devostasis/workscope/archive.py b/src/devostasis/workscope/archive.py new file mode 100644 index 0000000..cce68bb --- /dev/null +++ b/src/devostasis/workscope/archive.py @@ -0,0 +1,150 @@ +"""Bounded, deterministic private history transfer; never extract archive paths.""" + +from io import BytesIO +from pathlib import Path +import re +import stat +from zipfile import BadZipFile, ZIP_STORED, ZipFile, ZipInfo + +from ..canonical import canonical_bytes, digest, digest_bytes, loads +from .bundle import MEMBERS, atomic, publish, verify_content +from .model import fields, identity, instant, number, require +from .operations import MAX_BYTES, MAX_RECORDS, append_record, hash_ref, invocation, no_links, result_record, snapshot + +CONTRACT = "devostasis.work-history-archive.v1" + + +def export_history(store, expected, output, max_bytes=MAX_BYTES): + payload, _, _, latest_id = snapshot(store, expected, max_bytes) + index = {"contract": CONTRACT, "project": expected, "latest_bundle_id": latest_id, + "members": {name: {"digest": digest_bytes(data), "size": len(data)} for name, data in sorted(payload.items())}} + payload["index.json"] = canonical_bytes(index) + stream = BytesIO() + with ZipFile(stream, "w", compression=ZIP_STORED) as archive: + for name, data in sorted(payload.items()): + info = ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + info.create_system = 3; info.external_attr = (stat.S_IFREG | 0o600) << 16 + archive.writestr(info, data) + data = stream.getvalue() + require(len(data) <= max_bytes, "history archive byte cap") + dest = no_links(output); dest.parent.mkdir(parents=True, exist_ok=True); no_links(dest) + atomic(dest, data) + return {"contract": CONTRACT, "archive_digest": digest_bytes(data), "index_digest": digest(index), + "members": len(index["members"]), "bytes": len(data), "path": str(dest)} + + +def admit_archive(filename, expected, max_bytes=MAX_BYTES): + number(max_bytes, 1, 512 * 1024 * 1024) + filename = no_links(filename) + require(filename.is_file() and filename.stat().st_size <= max_bytes, "archive file unavailable or byte cap") + try: + with ZipFile(filename) as archive: + infos = archive.infolist() + require(1 <= len(infos) <= MAX_RECORDS * 8 + 1, "archive member count cap") + names = [i.filename for i in infos] + require(len(set(names)) == len(names) and "index.json" in names, "archive duplicate or missing index") + require(sum(i.file_size for i in infos) <= max_bytes, "archive expanded byte cap") + for info in infos: + mode = info.external_attr >> 16 + require(info.compress_type == ZIP_STORED and not info.flag_bits & 1 and + stat.S_IFMT(mode) == stat.S_IFREG and not info.is_dir(), "archive member type or compression refused") + require(info.filename == "index.json" or re.fullmatch( + r"bundles/[0-9a-f]{64}/(?:inventory\.json|policy\.json|evidence\.json|scope\.json|report\.md|manifest\.json)|runs/[0-9a-f]{32}\.json|results/[0-9a-f]{64}\.json", + info.filename), "archive path refused") + payload = {i.filename: archive.read(i) for i in infos} + except (BadZipFile, RuntimeError) as exc: + raise ValueError("archive integrity unavailable") from exc + index_bytes = payload.pop("index.json") + index = loads(index_bytes.decode("utf-8")) + fields(index, ("contract", "project", "latest_bundle_id", "members")) + require(index["contract"] == CONTRACT and index["project"] == expected, "archive project or contract mismatch") + # Validate the expected project even for a deliberately empty archive. + from .operations import project_root + project_root(filename.parent, expected) + require(canonical_bytes(index) == index_bytes, "archive index is not canonical") + require(isinstance(index["members"], dict) and set(index["members"]) == set(payload), "archive index member mismatch") + for name, data in payload.items(): + descriptor = index["members"][name]; fields(descriptor, ("digest", "size")) + require(type(descriptor["size"]) is int and descriptor["size"] == len(data) and descriptor["digest"] == digest_bytes(data), + "archive member digest or size mismatch") + bundles, content = {}, {} + for prefix in sorted({name.split("/")[1] for name in payload if name.startswith("bundles/")}): + members = {name: payload[f"bundles/{prefix}/{name}"] for name in (*MEMBERS, "manifest.json")} + decoded = verify_content(members); bid = decoded["manifest.json"]["bundle_id"] + require(bid == "sha256:" + prefix and identity(decoded["scope.json"]["subject"]) == expected, "archive bundle binding mismatch") + bundles[bid], content[bid] = decoded, members + require(len(bundles) <= MAX_RECORDS, "archive generation cap") + canonical = [b for b in bundles.values() if b["scope.json"]["subject"]["context"] == "CANONICAL"] + if index["latest_bundle_id"] is not None: + hash_ref(index["latest_bundle_id"]) + require(index["latest_bundle_id"] in bundles and bundles[index["latest_bundle_id"]] in canonical, "archive latest binding mismatch") + require(instant(bundles[index["latest_bundle_id"]]["scope.json"]["observed_at"]) == + max(instant(b["scope.json"]["observed_at"]) for b in canonical), "archive latest is not newest") + records = {"runs": [], "results": []} + for name, data in sorted(payload.items()): + category = name.split("/", 1)[0] + if category not in records: + continue + record = loads(data.decode("utf-8")); require(data == canonical_bytes(record), "archive record is not canonical") + if category == "runs": + require(record.get("bundle_id") is None or record["bundle_id"] in bundles, "archive invocation bundle unavailable") + invocation(record, bundles.get(record["bundle_id"])) + require(identity(record["binding"]["project"]) == expected and name == f"runs/{record['run_id']}.json", "archive invocation binding mismatch") + else: + require(record.get("scope_bundle_id") in bundles, "archive result bundle unavailable") + result_record(record, bundles[record["scope_bundle_id"]]) + require(name == f"results/{record['result_id'][7:]}.json", "archive result binding mismatch") + records[category].append(record) + require(all(len(values) <= MAX_RECORDS for values in records.values()), "archive operational record cap") + # Reject equal-time different canonical generations before any store mutation. + seen = {} + for b in canonical: + at = instant(b["scope.json"]["observed_at"]) + bid = b["manifest.json"]["bundle_id"] + require(at not in seen or seen[at] == bid, "archive same-time canonical collision") + seen[at] = bid + return content, records, index + + +def import_history(filename, expected, store, max_bytes=MAX_BYTES): + content, records, index = admit_archive(filename, expected, max_bytes) + # Verify the destination as well, and preflight collisions before appending. + from .operations import project_root + root = project_root(store, expected); root.mkdir(parents=True, exist_ok=True) + from .bundle import locked + # This import lock serializes importers; the ordinary per-bundle publisher + # still owns writer locking and monotonic latest promotion. + with locked(root / ".import-lock"): + old, existing, _, _ = snapshot(store, expected, max_bytes) + combined = dict(old) + for bid, members in content.items(): + for name, data in members.items(): + key = f"bundles/{bid[7:]}/{name}" + require(key not in old or old[key] == data, "history import immutable collision") + combined[key] = data + for category, values in records.items(): + for record in values: + ref = record["run_id"] if category == "runs" else record["result_id"][7:] + key = f"{category}/{ref}.json" + require(key not in old or old[key] == canonical_bytes(record), "history import record collision") + combined[key] = canonical_bytes(record) + require(sum(map(len, combined.values())) <= max_bytes, "combined history byte cap") + for category in ("bundles", "runs", "results"): + require(len({k.split("/")[1] for k in combined if k.startswith(category + "/")}) <= MAX_RECORDS, + "combined history record cap") + seen = {} + for bid, m in {**existing, **{bid: verify_content(m) for bid, m in content.items()}}.items(): + scope = m["scope.json"] + if scope["subject"]["context"] == "CANONICAL": + at = instant(scope["observed_at"]) + require(at not in seen or seen[at] == bid, "destination same-time canonical collision") + seen[at] = bid + ordered = sorted(content.values(), key=lambda m: (instant(loads(m["scope.json"].decode())["observed_at"]), digest_bytes(m["manifest.json"]))) + for members in ordered: + publish(store, members) + for category, values in records.items(): + for record in values: + ref = record["run_id"] if category == "runs" else record["result_id"][7:] + append_record(store, expected, category, ref, record) + return {"contract": CONTRACT, "status": "IMPORTED", "index_digest": digest(index), "bundles": len(content), + "invocations": len(records["runs"]), "caller_results": len(records["results"])} diff --git a/src/devostasis/workscope/bundle.py b/src/devostasis/workscope/bundle.py index 4d826a3..2de1ef2 100644 --- a/src/devostasis/workscope/bundle.py +++ b/src/devostasis/workscope/bundle.py @@ -86,6 +86,9 @@ def verify(path): @contextlib.contextmanager def locked(path): # OS locks release after process death; a leftover lock file is harmless. + path = Path(path).absolute() + require(all(not p.is_symlink() and not (hasattr(p, "is_junction") and p.is_junction()) + for p in (path, *path.parents)), "linked store lock path") handle = open(path, "a+b") if os.name == "nt": import msvcrt diff --git a/src/devostasis/workscope/cli.py b/src/devostasis/workscope/cli.py index 8339e57..5caacde 100644 --- a/src/devostasis/workscope/cli.py +++ b/src/devostasis/workscope/cli.py @@ -49,7 +49,7 @@ def client(args, provider=None, endpoint=None): require(not args.cache, "glab credential bridge requires cache disabled; use token-env for partitioned caching") transport = GlabTransport(endpoint) return Client(provider, endpoint, token, transport=transport, max_requests=args.max_requests, max_pages=args.max_pages, - seconds=args.seconds, cache=args.cache) + seconds=args.seconds, cache=args.cache, workers=args.workers) def recheck(decoded, current, at, *, actor, policy_version, expected_project): @@ -64,6 +64,36 @@ def recheck(decoded, current, at, *, actor, policy_version, expected_project): def cmd(args): try: + if args.operation in ("bind", "observe", "audit", "export-history", "import-history", "verify-history"): + from .operations import audit, make_binding, observe + from .archive import admit_archive, export_history, import_history + if args.operation == "observe": + def bound_client(value): + options = argparse.Namespace(**vars(args)) + options.provider, options.endpoint = value["project"]["provider"], value["project"]["endpoint"] + for key, setting in value["budget"].items(): + setattr(options, key, setting) + return client(options) + outcome = observe(read_json(args.binding), read_json(args.policy), args.store, bound_client, + args.at, read_json(args.evidence) if args.evidence else None) + emit(outcome, args.output); return 2 if outcome["status"] == "FAILED" else 0 + expected = {"provider": args.provider, "endpoint": args.endpoint or + ("https://api.github.com" if args.provider == "github" else "https://gitlab.com/api/v4"), + "project_id": args.expected_project_id} + if args.operation == "bind": + value = make_binding({**expected, "locator": args.repo}, read_json(args.policy), args.integration_id, args.enabled) + emit(value, args.output) + elif args.operation == "audit": + emit(audit(args.store, expected, args.at, args.max_bytes), args.output) + elif args.operation == "export-history": + emit(export_history(args.store, expected, args.output, args.max_bytes)) + elif args.operation == "import-history": + emit(import_history(args.archive, expected, args.store, args.max_bytes)) + else: + content, records, index = admit_archive(args.archive, expected, args.max_bytes) + emit({"status": "VERIFIED", "bundles": len(content), "invocations": len(records["runs"]), + "caller_results": len(records["results"]), "index_digest": digest(index)}) + return 0 if args.operation == "policy": emit(default_policy(args.actor), args.output); return 0 if args.operation == "import": @@ -95,6 +125,19 @@ def cmd(args): emit({"bundle_id": manifest["bundle_id"], "path": str(dest.resolve())}); return 0 decoded = latest(args.latest) if args.latest else verify(args.bundle) scope = decoded["scope.json"] + if args.operation in ("record-result", "verify-result"): + from .operations import record_result, result_record + expected = {"provider": args.provider, "endpoint": args.endpoint or + ("https://api.github.com" if args.provider == "github" else "https://gitlab.com/api/v4"), + "project_id": args.expected_project_id} + require(identity(scope["subject"]) == expected and args.actor == decoded["policy.json"]["actor"] and + args.policy_version == decoded["policy.json"]["version"], "result consumer binding mismatch") + if args.operation == "record-result": + emit(record_result(decoded, read_json(args.packet), read_json(args.result), args.store, args.at), args.output) + else: + record = result_record(read_json(args.record), decoded) + emit({"status": "VERIFIED", "outcome": record["claim"]["outcome"], "authority": record["authority"]}, args.output) + return 0 if args.operation in ("handoff", "verify-handoff"): from .handoff import prepare, verify_packet at = args.at or clock() @@ -156,6 +199,37 @@ def cmd(args): def add_parser(parser): work = parser.add_parser("work", help="Evidence to Action: bounded read-only work scopes") operations = work.add_subparsers(dest="operation", required=True) + from .operations import MAX_BYTES + for operation in ("bind", "observe", "audit", "export-history", "import-history", "verify-history", "record-result", "verify-result"): + p = operations.add_parser(operation); p.set_defaults(func=cmd) + if operation != "observe": + p.add_argument("--provider", choices=("github", "gitlab"), default="github") + p.add_argument("--endpoint"); p.add_argument("--expected-project-id", required=True) + if operation in ("bind", "observe"): + p.add_argument("--policy", required=True) + if operation == "bind": + p.add_argument("--repo", required=True); p.add_argument("--integration-id", required=True) + p.add_argument("--enabled", action="store_true", help="explicitly enable canonical observation") + if operation == "observe": + p.add_argument("--binding", required=True); p.add_argument("--evidence") + p.add_argument("--token-env"); p.add_argument("--cache") + if operation in ("observe", "audit", "export-history", "import-history", "record-result"): + p.add_argument("--store", required=True) + if operation in ("observe", "audit", "record-result"): + p.add_argument("--at") + if operation in ("bind", "observe", "audit", "export-history", "record-result", "verify-result"): + p.add_argument("--output", required=operation == "export-history") + if operation in ("audit", "export-history", "import-history", "verify-history"): + p.add_argument("--max-bytes", type=int, default=MAX_BYTES) + if operation in ("import-history", "verify-history"): + p.add_argument("--archive", required=True) + if operation in ("record-result", "verify-result"): + loc = p.add_mutually_exclusive_group(required=True); loc.add_argument("--bundle"); loc.add_argument("--latest") + p.add_argument("--actor", required=True); p.add_argument("--policy-version", required=True) + if operation == "record-result": + p.add_argument("--packet", required=True); p.add_argument("--result", required=True) + if operation == "verify-result": + p.add_argument("--record", required=True) for operation in ("policy", "import", "collect", "build", "run", "verify", "replay", "slice", "explain", "recheck", "handoff", "verify-handoff"): p = operations.add_parser(operation) p.set_defaults(func=cmd) @@ -179,6 +253,7 @@ def add_parser(parser): p.add_argument("--endpoint"); p.add_argument("--token-env"); p.add_argument("--cache") p.add_argument("--max-requests", type=int, default=100); p.add_argument("--max-pages", type=int, default=10) p.add_argument("--seconds", type=int, default=120) + p.add_argument("--workers", type=int, default=1, help="bounded parallel change reads (1..8); one shared budget") if operation in ("collect", "run"): p.add_argument("--repo", required=True); p.add_argument("--revision") p.add_argument("--context", choices=("CANONICAL", "CANDIDATE"), default="CANONICAL") diff --git a/src/devostasis/workscope/collect.py b/src/devostasis/workscope/collect.py index 3f5bd89..c43fae4 100644 --- a/src/devostasis/workscope/collect.py +++ b/src/devostasis/workscope/collect.py @@ -3,11 +3,13 @@ from __future__ import annotations from datetime import datetime, timezone +from concurrent.futures import ThreadPoolExecutor from http.client import HTTPException import hashlib import re from pathlib import Path import time +import threading from urllib.error import HTTPError, URLError from urllib.parse import quote, urlencode, urlsplit from urllib.request import HTTPRedirectHandler, Request, build_opener @@ -38,8 +40,19 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): class HTTPTransport: def __init__(self): + self._local = threading.local() self.opener = build_opener(SafeRedirect()) + @property + def opener(self): + if not hasattr(self._local, "opener"): + self._local.opener = build_opener(SafeRedirect()) + return self._local.opener + + @opener.setter + def opener(self, value): + self._local.opener = value + def request(self, method, url, headers, body, timeout): # GraphQL queries are the sole permitted POST, generated by this module. query = loads(body.decode("utf-8")).get("query", "") if body else "" @@ -70,17 +83,19 @@ def request(self, method, url, headers, body, timeout): class Client: def __init__(self, provider, endpoint, token=None, *, transport=None, max_requests=100, - max_pages=10, seconds=120, cache=None): + max_pages=10, seconds=120, cache=None, workers=1): require(provider in ("github", "gitlab"), "unsupported provider") parsed = urlsplit(endpoint) - require(parsed.scheme == "https" and parsed.hostname and not parsed.username and not parsed.query + require(parsed.scheme == "https" and parsed.hostname and parsed.username is None and parsed.password is None and not parsed.query and not parsed.fragment and not endpoint.endswith("/"), "invalid HTTPS endpoint") require(type(max_requests) is int and 1 <= max_requests <= 10000 and type(max_pages) is int and 1 <= max_pages <= 100 and type(seconds) is int and 1 <= seconds <= 3600, "invalid collection budget") + require(type(workers) is int and 1 <= workers <= 8, "invalid worker budget") self.provider, self.endpoint, self.token = provider, endpoint, token self.transport = transport or HTTPTransport() - self.max_requests, self.max_pages = max_requests, max_pages + self.max_requests, self.max_pages, self.seconds = max_requests, max_pages, seconds self.deadline = time.monotonic() + seconds + self.workers, self._budget_lock = workers, threading.Lock() self.receipts, self.telemetry, self.requests = [], [], 0 self.cache = Path(cache) if cache else None self.partition = digest({"provider": provider, "endpoint": endpoint, @@ -88,8 +103,9 @@ def __init__(self, provider, endpoint, token=None, *, transport=None, max_reques def get(self, path, params=None, *, query=None): require(path.startswith("/") and not path.startswith("//") and ".." not in path, "invalid API path") - require(self.requests < self.max_requests and time.monotonic() < self.deadline, "REQUEST_OR_TIME_CAP") - self.requests += 1 + with self._budget_lock: + require(self.requests < self.max_requests and time.monotonic() < self.deadline, "REQUEST_OR_TIME_CAP") + self.requests += 1 base = self.endpoint if path == "/graphql" and self.provider == "github" and base.endswith("/api/v3"): base = base[:-3] @@ -116,13 +132,16 @@ def get(self, path, params=None, *, query=None): require(path == "/graphql" and "mutation" not in query["query"], "only built-in read query permitted") headers["Content-Type"] = "application/json" started = time.monotonic() + receipt_recorded, telemetry = False, None try: code, response_headers, raw = self.transport.request(method, url, headers, data, max(0.1, min(20, self.deadline - started))) - self.telemetry.append({"path": path, "status": code, "bytes": len(raw), - "elapsed_ms": int((time.monotonic() - started) * 1000)}) + telemetry = {"path": path, "status": code, "bytes": len(raw), + "elapsed_ms": int((time.monotonic() - started) * 1000)} + self.telemetry.append(telemetry) if code not in (200, 304): self.receipts.append({"path": path, "status": code, "body_digest": None}) + receipt_recorded = True require(code in (200, 304), f"HTTP_{code}") if code == 304: require(cached is not None, "CACHE_304_WITHOUT_ADMITTED_BODY") @@ -131,14 +150,21 @@ def get(self, path, params=None, *, query=None): require(len(raw) <= MAX_RESPONSE, "RESPONSE_BYTE_CAP") body = loads(raw.decode("utf-8")) self.receipts.append({"path": path, "status": 200, "body_digest": digest(body)}) + receipt_recorded = True if cache_path and code == 200 and response_headers.get("etag"): try: cache_path.parent.mkdir(parents=True, exist_ok=True) atomic(cache_path, canonical_bytes({"url": url, "etag": response_headers["etag"], "body": body, "digest": digest(body)})) except OSError: - self.telemetry[-1]["cache_write_failed"] = True + telemetry["cache_write_failed"] = True return body, response_headers except (ValueError, UnicodeError, OSError, HTTPException) as exc: + reason = str(exc) if isinstance(exc, ScopeError) else "RESPONSE_OR_CACHE_UNAVAILABLE" + if not receipt_recorded: + self.receipts.append({"path": path, "status": "UNAVAILABLE", "body_digest": None, "reason": reason}) + if telemetry is None: + self.telemetry.append({"path": path, "status": None, "bytes": 0, + "elapsed_ms": int((time.monotonic() - started) * 1000), "reason": reason}) if isinstance(exc, ScopeError): raise raise ScopeError("RESPONSE_OR_CACHE_UNAVAILABLE") from exc @@ -228,6 +254,7 @@ def _gh_change(client, repo, num): route = f"/repos/{repo}/pulls/{num}" p, err = client.obj(route) require(p is not None, err or "CHANGE_UNAVAILABLE") + require(str(p.get("number")) == str(num), "CHANGE_IDENTITY_MISMATCH") head = p["head"]["sha"] raw_reviews = client.pages(route + "/reviews") reviews = _map(raw_reviews, lambda r: {"id": str(r["id"]), "actor": r["user"]["login"], @@ -275,6 +302,7 @@ def _gh_change(client, repo, num): threads = {"status": "PARTIAL" if thread_items else "UNAVAILABLE", "items": thread_items, "reasons": [str(exc) if isinstance(exc, ScopeError) else "GRAPHQL_MALFORMED"]}; break final, failure = client.obj(route) + require(final is None or str(final.get("number")) == str(num), "CHANGE_IDENTITY_MISMATCH") if final is None or final.get("head", {}).get("sha") != head or final.get("base", {}).get("sha") != p["base"]["sha"] or final.get("updated_at") != p["updated_at"]: reviews = unavailable(failure or "CHANGE_MOVED_DURING_COLLECTION") checks = unavailable(failure or "CHANGE_MOVED_DURING_COLLECTION") @@ -287,9 +315,10 @@ def _gh_change(client, repo, num): "reviews": reviews, "checks": checks, "threads": threads, "files": files} -def _gl_change(client, project, num): +def _gl_change(client, project, num, trains=None): route = f"/projects/{project}/merge_requests/{num}" p, err = client.obj(route); require(p is not None, err or "CHANGE_UNAVAILABLE") + require(str(p.get("iid")) == str(num), "CHANGE_IDENTITY_MISMATCH") head = p["sha"] # GitLab aggregate approval endpoint does not identify the reviewed SHA. # Never fabricate exact-head approvals from it; explicit unknown is safe. @@ -306,7 +335,8 @@ def discussion(d): count = p.get("changes_count") if not isinstance(count, str) or not count.isdigit() or int(count) != len(files["items"]): files["status"] = "PARTIAL"; files["reasons"] = sorted(set(files["reasons"] + ["FILES_RESULT_CAP_OR_UNKNOWN_COUNT"])) - trains = client.pages(f"/projects/{project}/merge_trains", {"scope": "active"}) + if trains is None: + trains = client.pages(f"/projects/{project}/merge_trains", {"scope": "active"}) train = None if trains["status"] == "COMPLETE": try: @@ -326,6 +356,7 @@ def discussion(d): "MANUAL" if j["status"] == "manual" else "UNKNOWN" if j["status"] == "skipped" else "PENDING", "at": timestamp(j.get("finished_at") or j.get("started_at") or j["created_at"])}) final, failure = client.obj(route) + require(final is None or str(final.get("iid")) == str(num), "CHANGE_IDENTITY_MISMATCH") if final is None or final.get("sha") != head or final.get("updated_at") != p["updated_at"]: checks = unavailable(failure or "CHANGE_MOVED_DURING_COLLECTION") threads = unavailable(failure or "CHANGE_MOVED_DURING_COLLECTION") @@ -391,11 +422,22 @@ def collect(client, locator, config, at=None, revision=None, context="CANONICAL" refs = [str(v["number"] if client.provider == "github" else v["iid"]) for v in changes["items"]] refs += [r for c in config["criteria"] for r in c["implementations"]] mapped_changes, errors = [], list(changes["reasons"]) - for ref in sorted(set(refs), key=lambda v: int(v)): + ordered = sorted(set(refs), key=lambda v: int(v)) + trains = client.pages(f"/projects/{locator}/merge_trains", {"scope": "active"}) if client.provider == "gitlab" and ordered else None + + def read_change(ref): try: - mapped_changes.append(change_fn(client, locator, ref)) + return (change_fn(client, locator, ref, trains) if client.provider == "gitlab" else + change_fn(client, locator, ref)), None except (ScopeError, KeyError, TypeError, ValueError) as exc: - errors.append(str(exc) if isinstance(exc, ScopeError) else "CHANGE_MALFORMED") + return None, str(exc) if isinstance(exc, ScopeError) else "CHANGE_MALFORMED" + + with ThreadPoolExecutor(max_workers=client.workers) as pool: + for change, error in pool.map(read_change, ordered): + if error: + errors.append(error) + else: + mapped_changes.append(change) changes = {"status": "PARTIAL" if errors and mapped_changes else "UNAVAILABLE" if errors else "COMPLETE", "items": mapped_changes, "reasons": sorted(set(errors))} issues = complete() @@ -408,6 +450,7 @@ def collect(client, locator, config, at=None, revision=None, context="CANONICAL" raw, failure = client.obj(project_route + "/issues/" + quote(ref, safe="")) try: require(raw is not None and "pull_request" not in raw, failure or "ISSUE_UNAVAILABLE") + require(str(raw.get("number" if client.provider == "github" else "iid")) == ref, "ISSUE_IDENTITY_MISMATCH") issues["items"].append(issue_fn(raw)) except (ScopeError, KeyError, TypeError, ValueError): issues["reasons"] = ["ISSUE_OR_DEPENDENCY_UNAVAILABLE"] @@ -415,6 +458,6 @@ def collect(client, locator, config, at=None, revision=None, context="CANONICAL" issues["status"] = "PARTIAL" if issues["items"] else "UNAVAILABLE" now = timestamp(at or datetime.now(timezone.utc).isoformat()) result = {"contract": CONTRACT, "kind": "inventory", "subject": project, "observed_at": now, - "changes": changes, "issues": issues, "receipts": client.receipts, + "changes": changes, "issues": issues, "receipts": sorted(client.receipts, key=canonical_bytes), "selection": {"changes": "SELECTED" if selected is not None or change_refs is not None else "OPEN_AND_REGISTERED", "issues": sorted(issue_refs)}} return inventory(result) diff --git a/src/devostasis/workscope/glab.py b/src/devostasis/workscope/glab.py index cd712c0..7c8b7d7 100644 --- a/src/devostasis/workscope/glab.py +++ b/src/devostasis/workscope/glab.py @@ -10,7 +10,7 @@ class GlabTransport: def __init__(self, endpoint, executable="glab"): parsed = urlsplit(endpoint) - require(parsed.scheme == "https" and parsed.path == "/api/v4" and not parsed.username + require(parsed.scheme == "https" and parsed.path == "/api/v4" and parsed.username is None and parsed.password is None and not parsed.query and not parsed.fragment, "glab requires an HTTPS /api/v4 endpoint") self.endpoint, self.host, self.executable = endpoint, parsed.netloc, executable diff --git a/src/devostasis/workscope/operations.py b/src/devostasis/workscope/operations.py new file mode 100644 index 0000000..da4b75b --- /dev/null +++ b/src/devostasis/workscope/operations.py @@ -0,0 +1,305 @@ +"""Explicit consumer bindings and immutable operational audit, outside bundles.""" + +from copy import deepcopy +from datetime import datetime, timezone +from pathlib import Path +import re +from urllib.parse import quote +from uuid import uuid4 + +from .. import __version__ +from ..canonical import canonical_bytes, digest, digest_bytes, loads +from .bundle import MEMBERS, atomic, build, latest, locked, publish, read, verify, verify_content +from .collect import collect +from .evidence import empty_evidence +from .handoff import verify_packet +from .model import fields, identity, instant, number, policy, require, strings, subject, text, timestamp + +ADOPTION = "devostasis.work-adoption.v1" +INVOCATION = "devostasis.work-invocation.v1" +RESULT = "devostasis.work-result.v1" +AUDIT = "devostasis.work-history-audit.v1" +MAX_BYTES = 64 * 1024 * 1024 +MAX_RECORDS = 2000 +AUTHORITY = "CALLER_REPORT_ONLY; no independent acceptance, claim, merge or deployment authority" + + +def clock(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def hash_ref(value): + require(isinstance(value, str) and re.fullmatch(r"sha256:[0-9a-f]{64}", value), "invalid digest reference") + return value + + +def no_links(path): + result = Path(path).absolute() + require(all(not p.is_symlink() and not (hasattr(p, "is_junction") and p.is_junction()) + for p in (result, *result.parents)), "linked operational store path") + return result + + +def project_root(store, expected, section="work"): + fields(expected, ("provider", "endpoint", "project_id")) + # Reuse the ordinary exact subject admission without inventing a revision. + subject({**expected, "locator": "validation", "revision": "0" * 40, "context": "CANONICAL"}) + return no_links(Path(store) / section / digest(expected)[7:]) + + +def binding(value, config=None): + fields(value, ("contract", "integration_id", "enabled", "engine_version", "project", "policy", "selection", "budget")) + require(value["contract"] == ADOPTION, "unsupported adoption contract") + text(value["integration_id"]); require(type(value["enabled"]) is bool, "explicit enabled boolean required") + text(value["engine_version"]) + fields(value["project"], ("provider", "endpoint", "project_id", "locator")) + subject({**value["project"], "revision": "0" * 40, "context": "CANONICAL"}) + locator = value["project"]["locator"] + require(re.fullmatch(r"[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)+", locator) or + value["project"]["provider"] == "gitlab" and locator.isdigit(), "invalid adoption locator") + require(all(part not in (".", "..") for part in locator.split("/")), "invalid adoption locator") + require(value["project"]["provider"] != "github" or len(locator.split("/")) == 2, "invalid GitHub adoption locator") + fields(value["policy"], ("actor", "version", "digest")) + text(value["policy"]["actor"]); text(value["policy"]["version"]); hash_ref(value["policy"]["digest"]) + fields(value["selection"], ("mode", "changes")) + require(value["selection"]["mode"] in ("OPEN_AND_REGISTERED", "REGISTERED", "CHANGES"), "invalid adoption selection") + strings(value["selection"]["changes"], "selected changes") + require(len(value["selection"]["changes"]) <= 100 and all(c.isdigit() for c in value["selection"]["changes"]), + "invalid bounded change selection") + require((value["selection"]["mode"] == "CHANGES") == bool(value["selection"]["changes"]), "selection mode mismatch") + fields(value["budget"], ("max_requests", "max_pages", "seconds", "workers")) + for key, high in (("max_requests", 10000), ("max_pages", 100), ("seconds", 3600), ("workers", 8)): + number(value["budget"][key], 1, high) + if config is not None: + policy(config) + require(value["policy"] == {"actor": config["actor"], "version": config["version"], "digest": digest(config)}, + "adoption policy binding mismatch") + canonical_bytes(value) + return value + + +def make_binding(project, config, integration_id, enabled=False): + return binding({"contract": ADOPTION, "integration_id": integration_id, "enabled": enabled, + "engine_version": __version__, "project": project, + "policy": {"actor": config["actor"], "version": config["version"], "digest": digest(config)}, + "selection": {"mode": "REGISTERED", "changes": []}, + "budget": {"max_requests": 100, "max_pages": 10, "seconds": 120, "workers": 4}}, config) + + +def append_record(store, expected, category, filename, record): + require(category in ("runs", "results") and re.fullmatch(r"[0-9a-f]{32}|[0-9a-f]{64}", filename), "invalid record path") + root = project_root(store, expected, "operations") + root.mkdir(parents=True, exist_ok=True); no_links(root) + with locked(root / ".lock"): + folder = no_links(root / category); folder.mkdir(exist_ok=True) + dest = no_links(folder / (filename + ".json")) + data = canonical_bytes(record) + require(len(data) <= MAX_BYTES, "operational record byte cap") + if dest.exists(): + require(dest.is_file() and dest.read_bytes() == data, "immutable operational record collision") + else: + atomic(dest, data) + return dest + + +def invocation(record, decoded=None): + fields(record, ("contract", "run_id", "binding", "binding_digest", "started_at", "finished_at", + "status", "bundle_id", "manifest_digest", "reasons", "telemetry")) + require(record["contract"] == INVOCATION and isinstance(record["run_id"], str) and + re.fullmatch(r"[0-9a-f]{32}", record["run_id"]), "unsupported invocation record") + binding(record["binding"]) + require(record["binding_digest"] == digest(record["binding"]), "invocation binding digest mismatch") + require(instant(record["started_at"]) <= instant(record["finished_at"]), "invocation clock mismatch") + require(record["status"] in ("COMPLETE", "PARTIAL", "FAILED", "DISABLED"), "invalid invocation status") + strings(record["reasons"], "invocation reasons") + fields(record["telemetry"], ("requests", "response_bytes", "workers")) + number(record["telemetry"]["requests"], 0, record["binding"]["budget"]["max_requests"]) + number(record["telemetry"]["response_bytes"], 0, 10000 * 4 * 1024 * 1024) + require(record["telemetry"]["workers"] == record["binding"]["budget"]["workers"], "invocation worker mismatch") + success = record["status"] in ("COMPLETE", "PARTIAL") + if not success: + require(record["bundle_id"] is None and record["manifest_digest"] is None and record["reasons"], "failure must not claim evidence") + require((record["status"] == "DISABLED") == (not record["binding"]["enabled"]), "disabled invocation mismatch") + require(record["status"] != "DISABLED" or + record["telemetry"]["requests"] == record["telemetry"]["response_bytes"] == 0, + "disabled invocation claimed network telemetry") + else: + require(record["binding"]["enabled"], "disabled invocation claimed a bundle") + hash_ref(record["bundle_id"]); hash_ref(record["manifest_digest"]) + if decoded is not None: + scope = decoded["scope.json"] + require(scope["subject"]["context"] == "CANONICAL" and + identity(scope["subject"]) == identity(record["binding"]["project"]) and + decoded["manifest.json"]["bundle_id"] == record["bundle_id"] and + digest(decoded["manifest.json"]) == record["manifest_digest"], "invocation bundle binding mismatch") + binding(record["binding"], decoded["policy.json"]) + inv = decoded["inventory.json"] + require(instant(record["started_at"]) <= instant(scope["observed_at"]) <= instant(record["finished_at"]), "invocation observation clock mismatch") + selected = record["binding"]["selection"]["mode"] != "OPEN_AND_REGISTERED" + require((inv["selection"]["changes"] == "SELECTED") == selected, "invocation selection mismatch") + if selected: + allowed = set(record["binding"]["selection"]["changes"]) | { + ref for criterion in decoded["policy.json"]["criteria"] for ref in criterion["implementations"]} + require(all(change["id"] in allowed for change in inv["changes"]["items"]), + "invocation selected change binding mismatch") + partial = bool(scope["gaps"]) or any(inv[k]["status"] != "COMPLETE" for k in ("changes", "issues")) + require(record["status"] == ("PARTIAL" if partial else "COMPLETE"), "invocation coverage mismatch") + return record + + +def observe(value, config, store, client_factory, at=None, evidence=None): + binding(value, config) + require(value["engine_version"] == __version__, "adoption engine version mismatch") + project_root(store, identity(value["project"])) + project_root(store, identity(value["project"]), "operations") + started = timestamp(at or clock()) + record = {"contract": INVOCATION, "run_id": uuid4().hex, "binding": deepcopy(value), "binding_digest": digest(value), + "started_at": started, "finished_at": started, "status": "DISABLED", "bundle_id": None, + "manifest_digest": None, "reasons": ["ADOPTION_DISABLED"], + "telemetry": {"requests": 0, "response_bytes": 0, "workers": value["budget"]["workers"]}} + dest, client = None, None + if value["enabled"]: + try: + client = client_factory(value) + require(client.provider == value["project"]["provider"] and client.endpoint == value["project"]["endpoint"] and + client.workers == value["budget"]["workers"] and client.max_requests == value["budget"]["max_requests"] and + client.max_pages == value["budget"]["max_pages"] and client.seconds == value["budget"]["seconds"], "adoption client binding mismatch") + locator = value["project"]["locator"] + meta, error = client.obj(("/repos/" if client.provider == "github" else "/projects/") + quote(locator, safe="/" if client.provider == "github" else "")) + require(meta is not None and str(meta.get("id")) == value["project"]["project_id"], error or "ADOPTION_PROJECT_MISMATCH") + selection = value["selection"] + refs = None if selection["mode"] == "OPEN_AND_REGISTERED" else selection["changes"] + inv = collect(client, locator, config, at, change_refs=refs) + require(identity(inv["subject"]) == identity(value["project"]), "ADOPTION_PROJECT_MISMATCH") + manifest, content = build(inv, config, evidence or empty_evidence()) + decoded = verify_content(content) + dest = publish(store, content) + partial = bool(decoded["scope.json"]["gaps"]) or any(inv[k]["status"] != "COMPLETE" for k in ("changes", "issues")) + record.update(status="PARTIAL" if partial else "COMPLETE", bundle_id=manifest["bundle_id"], + manifest_digest=digest_bytes(content["manifest.json"]), reasons=[]) + except Exception as exc: # Every failed invocation remains visible, including malformed provider data. + reason = re.sub(r"[\x00-\x1f]", " ", f"{type(exc).__name__}:{exc}")[:8192] + record.update(status="FAILED", reasons=[reason]) + record["finished_at"] = timestamp(at or clock()) + if client is not None: + record["telemetry"].update(requests=client.requests, response_bytes=sum(t["bytes"] for t in client.telemetry)) + invocation(record, verify(dest) if dest else None) + receipt = append_record(store, identity(value["project"]), "runs", record["run_id"], record) + return {"status": record["status"], "bundle_id": record["bundle_id"], "path": str(dest.resolve()) if dest else None, + "invocation": str(receipt.resolve()), "run_id": record["run_id"], "reasons": record["reasons"]} + + +def result_record(record, decoded): + fields(record, ("contract", "result_id", "scope_bundle_id", "packet", "claim", "recorded_at", "authority")) + require(record["contract"] == RESULT and record["result_id"] == digest({k: v for k, v in record.items() if k != "result_id"}), "result identity mismatch") + require(record["scope_bundle_id"] == decoded["manifest.json"]["bundle_id"] and record["authority"] == AUTHORITY, "result scope or authority mismatch") + config = decoded["policy.json"]; packet = record["packet"]; claim = record["claim"] + verify_packet(packet, decoded, packet["checked_at"], config["actor"], config["version"], identity(decoded["scope.json"]["subject"])) + require(instant(record["recorded_at"]) >= instant(packet["checked_at"]), "result clock predates handoff") + fields(claim, ("actor", "outcome", "acceptance", "notes")) + require(claim["actor"] == config["actor"] and claim["outcome"] in ("REPORTED_COMPLETE", "BLOCKED", "ABANDONED"), "result actor or outcome mismatch") + text(claim["notes"]); require(isinstance(claim["acceptance"], list), "result acceptance must be explicit") + require([a["criterion"] for a in claim["acceptance"]] == packet["item"]["acceptance"], "result acceptance coverage mismatch") + for answer in claim["acceptance"]: + fields(answer, ("criterion", "status", "evidence")) + require(answer["status"] in ("PASS", "FAIL", "UNKNOWN"), "invalid reported acceptance") + strings(answer["evidence"], "result evidence references") + require(answer["status"] != "PASS" or answer["evidence"], "reported pass requires evidence reference") + require(claim["outcome"] != "REPORTED_COMPLETE" or answer["status"] == "PASS", "incomplete acceptance cannot report completion") + canonical_bytes(record) + return record + + +def record_result(decoded, packet, claim, store, at=None): + record = {"contract": RESULT, "scope_bundle_id": decoded["manifest.json"]["bundle_id"], "packet": deepcopy(packet), + "claim": deepcopy(claim), "recorded_at": timestamp(at or clock()), "authority": AUTHORITY} + record["result_id"] = digest(record) + result_record(record, decoded) + project_root(store, identity(decoded["scope.json"]["subject"])) + # Preserve the referenced generation even when the caller uses another local store. + _, content = build(decoded["inventory.json"], decoded["policy.json"], decoded["evidence.json"], + decoded["scope.json"]["planned_at"], decoded["manifest.json"]["vitals_anchor"]) + publish(store, content) + dest = append_record(store, identity(decoded["scope.json"]["subject"]), "results", record["result_id"][7:], record) + return {"result_id": record["result_id"], "path": str(dest.resolve()), "outcome": claim["outcome"], "authority": AUTHORITY} + + +def snapshot(store, expected, max_bytes=MAX_BYTES): + """Read and verify the whole selected project; no filesystem extraction.""" + number(max_bytes, 1, 512 * 1024 * 1024) + root = project_root(store, expected) + payload, bundles, total = {}, {}, 0 + folders = root / "bundles" + if folders.exists(): + no_links(folders) + entries = sorted(folders.iterdir()) + require(len(entries) <= MAX_RECORDS, "history generation cap") + for directory in entries: + require(re.fullmatch(r"[0-9a-f]{64}", directory.name), "invalid history generation path") + no_links(directory) + require(sum((directory / name).stat().st_size for name in (*MEMBERS, "manifest.json")) <= max_bytes - total, + "history byte cap") + content = read(directory); total += sum(map(len, content.values())) + require(total <= max_bytes, "history byte cap") + decoded = verify_content(content); bid = decoded["manifest.json"]["bundle_id"] + require(bid[7:] == directory.name and identity(decoded["scope.json"]["subject"]) == expected, "history project or path binding mismatch") + bundles[bid] = decoded + payload.update({f"bundles/{directory.name}/{name}": data for name, data in content.items()}) + latest_id = None + canonical_times = {} + for bid, decoded in bundles.items(): + scope = decoded["scope.json"] + if scope["subject"]["context"] == "CANONICAL": + at = instant(scope["observed_at"]) + require(at not in canonical_times or canonical_times[at] == bid, "history same-time canonical collision") + canonical_times[at] = bid + if (root / "latest.json").exists(): + decoded = latest(root); latest_id = decoded["manifest.json"]["bundle_id"] + require(latest_id in bundles, "latest generation absent from history") + canonical = [v for v in bundles.values() if v["scope.json"]["subject"]["context"] == "CANONICAL"] + require(instant(decoded["scope.json"]["observed_at"]) == max(instant(v["scope.json"]["observed_at"]) for v in canonical), + "latest pointer is older than recorded canonical history") + records = {"runs": [], "results": []} + for category in records: + folder = project_root(store, expected, "operations") / category + if not folder.exists(): + continue + no_links(folder); entries = sorted(folder.iterdir()) + require(len(entries) <= MAX_RECORDS, "operational record count cap") + for filename in entries: + no_links(filename) + pattern = r"[0-9a-f]{32}\.json" if category == "runs" else r"[0-9a-f]{64}\.json" + require(re.fullmatch(pattern, filename.name) and filename.is_file() and filename.stat().st_size <= max_bytes - total, + "invalid operational record path or byte cap") + data = filename.read_bytes(); total += len(data); record = loads(data.decode("utf-8")) + require(data == canonical_bytes(record), "noncanonical operational record") + if category == "runs": + decoded = bundles.get(record.get("bundle_id")) + require(record.get("bundle_id") is None or decoded is not None, "invocation referenced bundle unavailable") + invocation(record, decoded) + require(identity(record["binding"]["project"]) == expected and filename.stem == record["run_id"], "invocation project/path mismatch") + else: + require(record.get("scope_bundle_id") in bundles, "result referenced bundle unavailable") + result_record(record, bundles[record["scope_bundle_id"]]) + require(filename.stem == record["result_id"][7:], "result path mismatch") + records[category].append(record) + payload[f"{category}/{filename.name}"] = data + return payload, bundles, records, latest_id + + +def audit(store, expected, at=None, max_bytes=MAX_BYTES): + _, bundles, records, latest_id = snapshot(store, expected, max_bytes) + canonical = [v["scope.json"] for v in bundles.values() if v["scope.json"]["subject"]["context"] == "CANONICAL"] + times = sorted(instant(s["observed_at"]) for s in canonical) + now = instant(at or clock()); current = bundles[latest_id]["scope.json"] if latest_id else None + status = "EMPTY" if not bundles else "LATEST_UNAVAILABLE" if current is None else "FUTURE" if instant(current["planned_at"]) > now else "EXPIRED" if instant(current["expires_at"]) < now else "AVAILABLE" + return {"contract": AUDIT, "project": expected, "status": status, "latest_bundle_id": latest_id, + "latest_coverage": current["coverage"] if current else None, "latest_gap_count": len(current["gaps"]) if current else None, + "bundles": len(bundles), "canonical_generations": len(canonical), "candidate_generations": len(bundles) - len(canonical), + "utc_calendar_days": len({t.date() for t in times}), + "first_observed_at": timestamp(times[0].isoformat()) if times else None, + "last_observed_at": timestamp(times[-1].isoformat()) if times else None, + "largest_gap_seconds": max((int((b - a).total_seconds()) for a, b in zip(times, times[1:])), default=0), + "invocations": {s: sum(r["status"] == s for r in records["runs"]) for s in ("COMPLETE", "PARTIAL", "FAILED", "DISABLED")}, + "caller_results": {s: sum(r["claim"]["outcome"] == s for r in records["results"]) for s in ("REPORTED_COMPLETE", "BLOCKED", "ABANDONED")}, + "authority": AUTHORITY} diff --git a/tests/test_release_pins.py b/tests/test_release_pins.py index 28e4019..56e83ad 100644 --- a/tests/test_release_pins.py +++ b/tests/test_release_pins.py @@ -29,9 +29,11 @@ Path(".github/workflows/work-observe.yml"), Path("examples/work/github-observe.yml"), Path("examples/work/gitlab-observe.yml"), + Path(".github/workflows/work-operations.yml"), + Path("examples/work/gitlab-operations.yml"), ) -WORKFLOW_REF = re.compile(r"(?:observe-self|work-observe)\.yml@(v[0-9][^\s\"']*)") +WORKFLOW_REF = re.compile(r"(?:observe-self|work-observe|work-operations)\.yml@(v[0-9][^\s\"']*)") # Case-insensitive and with the optional .git: GitHub resolves both spellings, # so a pin written either way is a pin that must resolve. INSTALL_REF = re.compile(r"github\.com/drevendev/devostasis(?:\.git)?@([^\s\"']+)", re.IGNORECASE) @@ -43,7 +45,7 @@ def test_the_package_version_and_the_module_agree(): assert declared == __version__, "pyproject.toml and devostasis.__version__ disagree" -@pytest.mark.parametrize("workflow_path", (".github/workflows/observe-self.yml", ".github/workflows/work-observe.yml")) +@pytest.mark.parametrize("workflow_path", (".github/workflows/observe-self.yml", ".github/workflows/work-observe.yml", ".github/workflows/work-operations.yml")) def test_the_reusable_workflow_installs_the_version_it_ships_with(workflow_path): """A caller pinning the workflow at this tag and passing no ref gets this engine.""" workflow = (ROOT / workflow_path).read_text(encoding="utf-8") @@ -63,6 +65,12 @@ def test_documented_pins_name_the_current_tag(relative: Path): assert found == TAG, f"{relative}: {what} {found} should be {TAG}" +@pytest.mark.parametrize("path", ("examples/work/gitlab-observe.yml", "examples/work/gitlab-operations.yml")) +def test_gitlab_install_default_names_the_current_tag(path): + match = re.search(r'DEVOSTASIS_REF:\s*"([^"]+)"', (ROOT / path).read_text()) + assert match and match.group(1) == TAG + + def test_the_changelog_has_a_section_for_this_version(): changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") assert re.search(rf"^## {re.escape(__version__)}(\s|\()", changelog, re.MULTILINE), ( diff --git a/tests/test_work_operations.py b/tests/test_work_operations.py new file mode 100644 index 0000000..4e3380a --- /dev/null +++ b/tests/test_work_operations.py @@ -0,0 +1,299 @@ +"""DEV-OPS: repeatable bound observation, portable history and reported outcomes.""" + +from copy import deepcopy +import json +from pathlib import Path +import stat +from zipfile import ZipFile, ZipInfo, ZIP_STORED, ZIP_DEFLATED + +import pytest + +from devostasis import __version__ +from devostasis.canonical import canonical_bytes, digest +from devostasis.cli import main +from devostasis.workscope.archive import admit_archive, export_history, import_history +from devostasis.workscope.bundle import build, publish, verify, verify_content +from devostasis.workscope.collect import Client +from devostasis.workscope.model import ScopeError, identity +from devostasis.workscope.operations import (AUTHORITY, append_record, audit, binding, make_binding, observe, + invocation, record_result, snapshot) +from test_work_handoff import AT, Transport, fixture, prepare_packet + +NEXT = "2026-10-07T12:00:00Z" + + +def setup(enabled=True): + decoded, _, _ = fixture() + config = decoded["policy.json"] + value = make_binding({**identity(decoded["scope.json"]["subject"]), "locator": "123"}, config, "test-pilot", enabled) + def factory(b): + return Client(b["project"]["provider"], b["project"]["endpoint"], transport=Transport(), **b["budget"]) + return value, config, factory + + +@pytest.mark.parametrize("field,value", [("enabled", None), ("contract", "devostasis.work-adoption.v999"), + ("engine_version", "future"), ("policy", {"actor": "another", "version": "example-1", "digest": "sha256:" + "0" * 64})]) +def test_bad_binding_refuses_network_and_publication(tmp_path, field, value): + b, p, _ = setup(); b[field] = value + def forbidden(_): + pytest.fail("inadmissible binding must not create a network client") + with pytest.raises(ScopeError): + observe(b, p, tmp_path, forbidden, AT) + assert not list(tmp_path.iterdir()) + + +def test_disabled_binding_records_negative_control_without_canonical_write(tmp_path): + b, p, _ = setup(False) + result = observe(b, p, tmp_path, lambda _: pytest.fail("disabled caller requested a client"), AT) + assert result["status"] == "DISABLED" and result["bundle_id"] is None + assert not (tmp_path / "work").exists() + assert audit(tmp_path, identity(b["project"]), AT)["invocations"]["DISABLED"] == 1 + + +def test_disabled_receipt_cannot_claim_received_network_bytes(tmp_path): + b, p, _ = setup(False) + result = observe(b, p, tmp_path, lambda _: pytest.fail("disabled caller requested a client"), AT) + receipt = json.loads(Path(result["invocation"]).read_text()); receipt["telemetry"]["response_bytes"] = 1 + with pytest.raises(ScopeError, match="network telemetry"): + invocation(receipt) + + +def test_rehashed_binding_cannot_claim_another_selected_change(tmp_path): + from test_workscope_adapters import Routes, gitlab_routes + from test_workscope import config + p = config(); p["criteria"][0]["implementations"] = ["1"] + b = make_binding({"provider": "gitlab", "endpoint": "https://gitlab.example/api/v4", + "project_id": "123", "locator": "123"}, p, "selected", True) + result = observe(b, p, tmp_path, lambda b: Client("gitlab", b["project"]["endpoint"], + transport=Routes(gitlab_routes()), **b["budget"]), AT) + receipt = json.loads(Path(result["invocation"]).read_text()) + p = deepcopy(p); p["criteria"][0]["implementations"] = [] + decoded = verify(result["path"]) + _, content = build(decoded["inventory.json"], p, decoded["evidence.json"]) + decoded = verify_content(content) + receipt["binding"]["policy"]["digest"] = digest(p) + receipt["binding_digest"] = digest(receipt["binding"]) + receipt["bundle_id"] = decoded["manifest.json"]["bundle_id"] + receipt["manifest_digest"] = digest(decoded["manifest.json"]) + with pytest.raises(ScopeError, match="selected change binding"): + invocation(receipt, decoded) + + +def test_repeated_bound_generations_and_calendar_days_survive_transfer(tmp_path): + b, p, factory = setup(); expected = identity(b["project"]) + source, dest = tmp_path / "source", tmp_path / "restored" + first = observe(b, p, source, factory, AT); second = observe(b, p, source, factory, NEXT) + assert first["status"] == second["status"] == "COMPLETE" + assert first["bundle_id"] != second["bundle_id"] + before = audit(source, expected, NEXT) + assert before["utc_calendar_days"] == 2 and before["largest_gap_seconds"] == 86400 + assert before["latest_bundle_id"] == second["bundle_id"] + archive = tmp_path / "private.zip"; again = tmp_path / "again.zip" + export_history(source, expected, archive); export_history(source, expected, again) + assert archive.read_bytes() == again.read_bytes() + import_history(archive, expected, dest); import_history(archive, expected, dest) + assert audit(dest, expected, NEXT) == before + assert audit(dest, expected, "2026-10-09T12:00:00Z")["status"] == "EXPIRED" + assert audit(dest, expected, "2026-10-05T12:00:00Z")["status"] == "FUTURE" + + +def test_backfill_and_candidates_never_move_latest_or_add_canonical_days(tmp_path): + b, p, factory = setup(); expected = identity(b["project"]) + result = observe(b, p, tmp_path, factory, NEXT) + observe(b, p, tmp_path, factory, AT) + decoded = verify(result["path"]); inv = deepcopy(decoded["inventory.json"]) + inv["subject"]["context"] = "CANDIDATE" + inv["subject"]["source_binding"]["kind"] = "BRANCH" + _, content = build(inv, p, decoded["evidence.json"]) + publish(tmp_path, content) + report = audit(tmp_path, expected, NEXT) + assert report["latest_bundle_id"] == result["bundle_id"] and report["candidate_generations"] == 1 + assert report["utc_calendar_days"] == 2 + + +def test_wrong_immutable_project_stops_before_inventory_and_records_failure(tmp_path): + b, p, factory = setup(); b["project"]["project_id"] = "999" + result = observe(b, p, tmp_path, factory, AT) + assert result["status"] == "FAILED" and result["path"] is None + receipt = json.loads(Path(result["invocation"]).read_text()) + assert receipt["telemetry"]["requests"] == 1 and "ADOPTION_PROJECT_MISMATCH" in receipt["reasons"][0] + assert not (tmp_path / "work").exists() + + +def test_provider_failure_and_partial_collection_are_distinct_audited_states(tmp_path): + b, p, factory = setup() + def fail(_): + raise OSError("provider unavailable") + failed = observe(b, p, tmp_path, fail, AT) + assert failed["status"] == "FAILED" + b["selection"] = {"mode": "CHANGES", "changes": ["99"]} + class Missing(Transport): + def request(self, method, url, headers, body, timeout): + if "/merge" in url or "/merge_trains" in url: + return 404, {}, b"{}" + return super().request(method, url, headers, body, timeout) + partial = observe(b, p, tmp_path, lambda b: Client("gitlab", b["project"]["endpoint"], transport=Missing(), **b["budget"]), NEXT) + assert partial["status"] == "PARTIAL" and partial["bundle_id"] + report = audit(tmp_path, identity(b["project"]), NEXT) + assert report["invocations"]["PARTIAL"] == report["invocations"]["FAILED"] == 1 + + +def claim(packet): + return {"actor": "executor", "outcome": "REPORTED_COMPLETE", "notes": "Bounded source qualification completed.", + "acceptance": [{"criterion": c, "status": "PASS", "evidence": ["local:qualification.json"]} for c in packet["item"]["acceptance"]]} + + +def test_packet_bound_result_remains_caller_report_after_expiry_and_restore(tmp_path): + decoded, client, _ = fixture(); packet = prepare_packet(decoded, client) + result = record_result(decoded, packet, claim(packet), tmp_path / "source", NEXT) + assert result["outcome"] == "REPORTED_COMPLETE" and result["authority"] == AUTHORITY + expected = identity(decoded["scope.json"]["subject"]) + export_history(tmp_path / "source", expected, tmp_path / "private.zip") + import_history(tmp_path / "private.zip", expected, tmp_path / "restored") + assert audit(tmp_path / "restored", expected, NEXT)["caller_results"]["REPORTED_COMPLETE"] == 1 + + +@pytest.mark.parametrize("mutation", ["actor", "missing", "unknown", "evidence", "packet", "clock"]) +def test_outcome_cannot_override_binding_acceptance_or_packet(tmp_path, mutation): + decoded, client, _ = fixture(); packet = prepare_packet(decoded, client); result = claim(packet); at = NEXT + if mutation == "actor": result["actor"] = "another actor" + if mutation == "missing": result["acceptance"] = [] + if mutation == "unknown": result["acceptance"][0]["status"] = "UNKNOWN" + if mutation == "evidence": result["acceptance"][0]["evidence"] = [] + if mutation == "packet": packet["total_bytes"] = 999 + if mutation == "clock": at = "2026-10-05T12:00:00Z" + with pytest.raises(ScopeError): + record_result(decoded, packet, result, tmp_path, at) + assert not list(tmp_path.iterdir()) + + +def alter_archive(original, output, mutation): + with ZipFile(original) as z: + members = {i.filename: z.read(i) for i in z.infolist()} + index = json.loads(members["index.json"]) + if mutation == "foreign": + index["project"]["project_id"] = "999"; members["index.json"] = canonical_bytes(index) + if mutation == "digest": + name = next(k for k in members if k.endswith("report.md")); members[name] += b"forged" + if mutation == "path": members["../escape"] = b"escape" + if mutation == "missing": members.pop(next(k for k in members if k.endswith("scope.json"))) + if mutation == "rehashed": + name = next(k for k in members if k.endswith("scope.json")) + scope = json.loads(members[name]); scope["authority"] = "merge anything"; members[name] = canonical_bytes(scope) + index["members"][name] = {"size": len(members[name]), "digest": digest(scope)} + members["index.json"] = canonical_bytes(index) + with ZipFile(output, "w") as z: + for name, data in members.items(): + info = ZipInfo(name); info.create_system = 3 + info.external_attr = (stat.S_IFLNK if mutation == "link" and name.endswith("report.md") else stat.S_IFREG) << 16 + info.compress_type = ZIP_DEFLATED if mutation == "compressed" else ZIP_STORED + z.writestr(info, data) + if mutation == "duplicate": + with pytest.warns(UserWarning): z.writestr(ZipInfo("index.json"), members["index.json"]) + + +@pytest.mark.parametrize("mutation", ["foreign", "digest", "path", "missing", "rehashed", "link", "compressed", "duplicate"]) +def test_untrusted_archive_is_fully_refused_before_store_mutation(tmp_path, mutation): + b, p, factory = setup(); expected = identity(b["project"]) + observe(b, p, tmp_path / "source", factory, AT) + export_history(tmp_path / "source", expected, tmp_path / "original.zip") + alter_archive(tmp_path / "original.zip", tmp_path / "bad.zip", mutation) + with pytest.raises((ScopeError, KeyError, ValueError)): + import_history(tmp_path / "bad.zip", expected, tmp_path / "target") + assert not (tmp_path / "target").exists() and not (tmp_path / "escape").exists() + + +def test_caps_existing_collisions_and_changed_history_fail_closed(tmp_path): + b, p, factory = setup(); expected = identity(b["project"]) + result = observe(b, p, tmp_path / "source", factory, AT) + archive = tmp_path / "private.zip"; export_history(tmp_path / "source", expected, archive) + with pytest.raises(ScopeError, match="byte cap"): + admit_archive(archive, expected, 1) + decoded = verify(result["path"]); decoded["policy.json"]["version"] = "different" + _, conflict = build(decoded["inventory.json"], decoded["policy.json"], decoded["evidence.json"]) + publish(tmp_path / "target", conflict) + before = snapshot(tmp_path / "target", expected)[0] + with pytest.raises(ScopeError, match="same-time"): + import_history(archive, expected, tmp_path / "target") + assert snapshot(tmp_path / "target", expected)[0] == before + report = Path(result["path"]) / "report.md"; report.write_bytes(report.read_bytes() + b"forged") + with pytest.raises(ScopeError): + export_history(tmp_path / "source", expected, tmp_path / "bad.zip") + assert not (tmp_path / "bad.zip").exists() + + +def test_invocation_ids_never_replace_an_existing_record(tmp_path): + b, p, factory = setup(); result = observe(b, p, tmp_path, factory, AT) + record = json.loads(Path(result["invocation"]).read_text()); record["status"] = "FAILED" + with pytest.raises(ScopeError, match="collision"): + append_record(tmp_path, identity(b["project"]), "runs", record["run_id"], record) + + +def test_linked_store_ancestor_refuses_network_and_publication(tmp_path, monkeypatch): + b, p, _ = setup() + original = Path.is_symlink + monkeypatch.setattr(Path, "is_symlink", lambda self: self == tmp_path or original(self)) + with pytest.raises(ScopeError, match="linked"): + observe(b, p, tmp_path / "nested", lambda _: pytest.fail("linked store requested network"), AT) + assert not list(tmp_path.iterdir()) + + +def test_linked_lock_is_refused_before_open(tmp_path, monkeypatch): + from devostasis.workscope.bundle import locked + lock = tmp_path / ".lock"; original = Path.is_symlink + monkeypatch.setattr(Path, "is_symlink", lambda self: self == lock or original(self)) + with pytest.raises(ScopeError, match="linked"): + with locked(lock): + pytest.fail("linked lock opened") + assert not lock.exists() + + +def test_unbounded_failure_text_remains_a_bounded_visible_receipt(tmp_path): + b, p, _ = setup() + def fail(_): + raise ValueError("line\n" * 10000) + result = observe(b, p, tmp_path, fail, AT) + assert result["status"] == "FAILED" and len(result["reasons"][0]) == 8192 + + +def test_public_operation_schemas_and_example_match_written_shapes(tmp_path): + root = Path(__file__).parents[1] + b, p, factory = setup(); result = observe(b, p, tmp_path, factory, AT) + receipt = json.loads(Path(result["invocation"]).read_text()) + decoded, client, _ = fixture(); packet = prepare_packet(decoded, client) + outcome = record_result(decoded, packet, claim(packet), tmp_path / "result", NEXT) + record = json.loads(Path(outcome["path"]).read_text()) + for filename, value in (("work-adoption.schema.json", b), ("work-invocation.schema.json", receipt), ("work-result.schema.json", record)): + schema = json.loads((root / "schemas" / filename).read_text()) + assert set(schema["required"]) == set(schema["properties"]) == set(value) + assert schema["additionalProperties"] is False + example = json.loads((root / "examples/work/adoption.json").read_text()) + binding(example, json.loads((root / "examples/work/policy.json").read_text())) + assert example["engine_version"] == __version__ and not example["enabled"] + + +def test_operations_cli_provides_offline_binding_audit_transfer_and_outcome(tmp_path, capsys): + b, p, factory = setup(); expected = identity(b["project"]) + policy_file = tmp_path / "policy.json"; policy_file.write_bytes(canonical_bytes(p)) + binding_file = tmp_path / "binding.json" + common = ["--provider", "gitlab", "--endpoint", expected["endpoint"], "--expected-project-id", "123"] + assert main(["work", "bind", *common, "--repo", "123", "--policy", str(policy_file), "--integration-id", "test", "--output", str(binding_file)]) == 0 + assert not json.loads(binding_file.read_text())["enabled"] + assert main(["work", "observe", "--binding", str(binding_file), "--policy", str(policy_file), "--store", str(tmp_path / "disabled"), "--at", AT]) == 0 + capsys.readouterr() + observe(b, p, tmp_path / "source", factory, AT) + assert main(["work", "audit", *common, "--store", str(tmp_path / "source"), "--at", AT]) == 0 + assert json.loads(capsys.readouterr().out)["canonical_generations"] == 1 + archive = str(tmp_path / "private.zip") + assert main(["work", "export-history", *common, "--store", str(tmp_path / "source"), "--output", archive]) == 0; capsys.readouterr() + assert main(["work", "verify-history", *common, "--archive", archive]) == 0; capsys.readouterr() + assert main(["work", "import-history", *common, "--archive", archive, "--store", str(tmp_path / "restored")]) == 0 + assert json.loads(capsys.readouterr().out)["invocations"] == 1 + decoded, client, _ = fixture(); packet = prepare_packet(decoded, client) + packet_file, claim_file = tmp_path / "packet.json", tmp_path / "claim.json" + packet_file.write_bytes(canonical_bytes(packet)); claim_file.write_bytes(canonical_bytes(claim(packet))) + dest = publish(tmp_path / "result-store", build(decoded["inventory.json"], p, decoded["evidence.json"])[1]) + bindings = [*common, "--actor", "executor", "--policy-version", "example-1", "--bundle", str(dest)] + assert main(["work", "record-result", *bindings, "--packet", str(packet_file), "--result", str(claim_file), "--store", str(tmp_path / "result-store"), "--at", NEXT]) == 0 + record = json.loads(capsys.readouterr().out) + assert main(["work", "verify-result", *bindings, "--record", record["path"]]) == 0 diff --git a/tests/test_work_parallel.py b/tests/test_work_parallel.py new file mode 100644 index 0000000..50150fa --- /dev/null +++ b/tests/test_work_parallel.py @@ -0,0 +1,113 @@ +"""Shared quotas and scheduling-independent canonical collection evidence.""" + +from concurrent.futures import ThreadPoolExecutor +from copy import deepcopy +import threading +import time + +import pytest + +from devostasis.canonical import canonical_bytes +from devostasis.workscope.collect import Client, collect +from devostasis.workscope.model import ScopeError +from test_workscope_adapters import Routes, gitlab_routes, issue_routes +from test_workscope import AT, HEAD, BASE, config + + +@pytest.mark.parametrize("endpoint", ["https://:password@host/api/v4", "https://user:password@host/api/v4", "https://user@host/api/v4", "https://:@host/api/v4"]) +def test_userinfo_is_refused_before_any_request(endpoint): + with pytest.raises(ScopeError, match="HTTPS"): + Client("gitlab", endpoint) + + +def test_parallel_requests_cannot_overdraw_shared_quota_or_ignore_deadline(): + class Slow(Routes): + def request(self, *args): + time.sleep(0.01) + return super().request(*args) + transport = Slow({"/api/v4/test": (200, {}, {})}) + client = Client("gitlab", "https://gitlab.example/api/v4", transport=transport, max_requests=5, workers=8) + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(lambda _: client.obj("/test"), range(30))) + assert client.requests == len(transport.calls) == 5 + assert sum(obj is not None for obj, error in results) == 5 + client.deadline = time.monotonic() - 1 + assert client.obj("/test")[1] == "REQUEST_OR_TIME_CAP" and len(transport.calls) == 5 + + +def multi_routes(): + routes = gitlab_routes(); root = "/api/v4/projects/123"; first = deepcopy(routes[root + "/merge_requests/1"][2]) + second = deepcopy(first); second.update(id=100, iid=2) + routes[root + "/merge_requests"] = (200, {"x-next-page": ""}, [second, first]) + for name, reply in list(routes.items()): + if "/merge_requests/1" in name: + routes[name.replace("/merge_requests/1", "/merge_requests/2")] = deepcopy(reply) + routes[root + "/merge_requests/2"] = (200, {}, second) + return routes + + +def test_concurrent_change_reads_keep_exact_evidence_and_reuse_train_inventory(): + class Overlap(Routes): + def __init__(self, routes): + super().__init__(routes); self.lock = threading.Lock(); self.active = self.peak = 0 + def request(self, *args): + with self.lock: + self.active += 1; self.peak = max(self.peak, self.active) + time.sleep(0.005) + try: + return super().request(*args) + finally: + with self.lock: self.active -= 1 + sequential = Routes(multi_routes()); concurrent = Overlap(multi_routes()) + a = collect(Client("gitlab", "https://gitlab.example/api/v4", transport=sequential, workers=1), "123", config(), AT) + b = collect(Client("gitlab", "https://gitlab.example/api/v4", transport=concurrent, workers=4), "123", config(), AT) + assert canonical_bytes(a) == canonical_bytes(b) and concurrent.peak > 1 + assert sum("/merge_trains?" in c[1] for c in concurrent.calls) == 1 + assert all(c["reviews"]["status"] == "UNAVAILABLE" for c in b["changes"]["items"]) + + +def test_capped_parallel_collection_keeps_returned_records_and_explicit_gaps(): + client = Client("gitlab", "https://gitlab.example/api/v4", transport=Routes(multi_routes()), workers=4, max_requests=9) + inv = collect(client, "123", config(), AT) + assert client.requests == 9 and inv["changes"]["status"] != "COMPLETE" + assert inv["changes"]["reasons"] + + +def test_transport_failure_retains_bounded_source_recovery_path(): + class Failed: + def request(self, *args): + raise ScopeError("GLAB_AUTH_OR_RESPONSE_UNAVAILABLE") + client = Client("gitlab", "https://gitlab.example/api/v4", transport=Failed()) + assert client.obj("/projects/123/merge_requests/7")[0] is None + assert client.receipts == [{"path": "/projects/123/merge_requests/7", "status": "UNAVAILABLE", + "body_digest": None, "reason": "GLAB_AUTH_OR_RESPONSE_UNAVAILABLE"}] + assert client.telemetry[0]["bytes"] == 0 and client.requests == 1 + + +@pytest.mark.parametrize("provider", ["github", "gitlab"]) +@pytest.mark.parametrize("phase", ["initial", "final"]) +def test_wrong_native_change_identity_never_satisfies_selected_collection(provider, phase): + routes, endpoint, locator, root = issue_routes(provider) + key = "number" if provider == "github" else "iid" + detail = (gitlab_routes()[root + "/merge_requests/1"][2] if provider == "gitlab" else + {"number": 1, "title": "Change", "state": "open", "head": {"sha": HEAD}, "base": {"sha": BASE}, + "user": {"login": "owner"}, "updated_at": AT, "changed_files": 0}) + route = root + ("/pulls/1" if provider == "github" else "/merge_requests/1") + counter = 0 + def reply(_): + nonlocal counter + counter += 1 + return 200, {}, {**detail, key: 9} if phase == "initial" or counter > 1 else detail + routes[route] = reply + inv = collect(Client(provider, endpoint, transport=Routes(routes)), locator, config(), AT, change_refs=["1"]) + assert inv["changes"]["status"] == "UNAVAILABLE" and not inv["changes"]["items"] + assert "CHANGE_IDENTITY_MISMATCH" in inv["changes"]["reasons"] + assert counter == (1 if phase == "initial" else 2) + + +@pytest.mark.parametrize("provider", ["github", "gitlab"]) +def test_wrong_native_issue_identity_never_satisfies_registered_criterion(provider): + routes, endpoint, locator, root = issue_routes(provider) + routes[root + "/issues/2"][2]["number" if provider == "github" else "iid"] = 9 + inv = collect(Client(provider, endpoint, transport=Routes(routes)), locator, config(), AT, change_refs=[]) + assert inv["issues"]["status"] == "UNAVAILABLE" and not inv["issues"]["items"] From aa642b3c599d634a6b167c8d635bc5a024411d23 Mon Sep 17 00:00:00 2001 From: Aleksei Bogun Date: Wed, 7 Oct 2026 11:56:27 +0300 Subject: [PATCH 19/33] Keep external adopter qualification details in private history --- .devostasis/debt.json | 2 +- CHANGELOG.md | 21 +++++++++------------ ROADMAP.md | 38 +++++++++++++++++--------------------- docs/external-adoption.md | 23 ++++++++--------------- 4 files changed, 35 insertions(+), 49 deletions(-) diff --git a/.devostasis/debt.json b/.devostasis/debt.json index 0cf1690..b607e08 100644 --- a/.devostasis/debt.json +++ b/.devostasis/debt.json @@ -17,7 +17,7 @@ "state": "open", "opened": "2026-10-06", "updated": "2026-10-07", - "cost": "Bounded shared-budget concurrency and one per-generation train inventory improve throughput, but the external full probe remained PARTIAL (25 detailed MRs, 163 requests, 165300 ms). Bridge/native response qualification and complete repeated generations remain issue #62; selected inventory must not be called the complete backlog." + "cost": "Bounded shared-budget concurrency and one per-generation train inventory improve throughput. Bridge/native response qualification and complete repeated generations remain issue #62; selected inventory must not be called the complete backlog. Detailed adopter qualification evidence stays private." }, { "id": "D-9", diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c81042..a0f3ed1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,12 +25,10 @@ Consumer Operations, target E3. - Protected GitHub/GitLab caller recipes, synthetic disabled binding, public schemas and CI end-to-end archive restore checks. Existing core/work canonical lineages and historical replay remain unchanged; no runtime dependency added. -- External shadow qualification reused existing glab authentication: 19 requests, - two pinned files / 33,030 bytes, packet-bound reported research result, disabled - control and restored history spanning two actual UTC dates. Full parallel probe - admitted 25 MRs in 163 requests / 165,300 ms but remained PARTIAL due to bridge - response failures; #62 stays open. Production scheduling and B7's sustained - calibration remain deployment gates, not inferred from these two dates. +- Existing glab authentication can be reused by the bound caller. Detailed + external qualification evidence belongs in the adopter's private history. + Full bridge collection (#62), production scheduling and B7's sustained + calibration remain deployment gates. - Reject password-only endpoint userinfo before network reads (#66). - Reject substituted native PR/MR/issue identities during selected collection and final change rechecks (#68); verify returned selected refs during history @@ -63,12 +61,11 @@ Reproducible consumer handoff, target E2. - Reuse an existing host-specific glab login without extracting credentials; resolve namespaced GitLab projects; support explicitly selected/registered change inventories. Credential-partitioned caching requires token-env mode. -- First external GitLab shadow qualification: one declared research task, - two source files / 33,030 bytes / 18 requests, immutable scope and offline - packet verification. Deleted merged branch was a negative control. This is - initial adoption evidence; sustained private CI and production activation - remain separate deployment qualifications. Large-project throughput is #62; - exact-head GitLab approvals remain UNKNOWN under #58. +- External GitLab shadow path supports declared research tasks, immutable scope, + offline packet verification and deleted-ref controls. Keep detailed adopter + qualification evidence private. Sustained private CI and production activation + remain separate qualifications. Large-project throughput is #62; exact-head + GitLab approvals remain UNKNOWN under #58. ## 0.3.0 (unreleased) diff --git a/ROADMAP.md b/ROADMAP.md index d19523c..cf4c110 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,14 +14,11 @@ packet-bound caller outcomes, full private-history audit/export/restore and protected GitHub/GitLab caller recipes. Existing canonical core/work lineages and historical replay remain unchanged. See [work-operations.md](docs/work-operations.md). -The selected outside GitLab consumer has two actual dated shadow generations -(2026-10-06/07), a verified bounded research result, disabled-caller negative -control and a restored portable history. The second selected path took 19 -requests for two files / 33,030 bytes. The full parallel probe returned 25 MRs -in 163 requests / 165,300 ms but remained PARTIAL because some bridge responses -were unavailable; #62 is still open. Missing records are explicit evidence -gaps, not an empty backlog. The local caller proof does not claim a protected -production runner, installed schedule or sustained B7 calibration. +An outside GitLab consumer is selected. Keep deployment qualification evidence, +source bytes, timing, inventory counts and calendar history in its private issue +and durable store. Full bridge collection qualification remains #62. Missing +records remain explicit evidence gaps. Protected production runner/scheduling +and sustained B7 calibration remain separate gates. The next gates are review/release of the prepared increments, activation of the reviewed pinned caller in the chosen private deployment, sustained calendar @@ -40,13 +37,12 @@ admission, work v2 named mutable sources and enforced pinned source packets. Historical core/work examples remain replayable. See [external-adoption.md](docs/external-adoption.md) for the consumer path. -The owner's selected external GitLab pilot completed one read-only shadow -qualification: verified scope/slice/packet, two files / 33,030 bytes / 18 reads, -and a real deleted-ref negative control. Existing glab credentials were reused. +The external GitLab path supports verified scope/slice/packet and deleted-ref +negative controls with existing glab credentials. Detailed pilot evidence stays +in the adopter's private issue/history. Issue #61 records credential/locator adoption; #62 records full-inventory -throughput debt. This completes the initial external consumer path. It does -not establish sustained history, an installed production caller or exact-head -GitLab approval qualification (#58). E2 does not close B1, B7, private fleet +throughput debt. Sustained history, an installed production caller and exact-head +GitLab approval qualification (#58) remain open. E2 does not close B1, B7, private fleet recovery (#32) or the deferred core GitLab/Instrument carrier. Production activation and sustained calibration remain the next deployment milestone. @@ -231,7 +227,7 @@ statement; the fix belongs in this repository, not in the rule. | Item | Owner of the next step | Blocker | | --- | --- | --- | | B1 vectors | research process, then this repository | `PV-TEST-001` is being produced as `PV-TEST-VECTORS-00n` units; their findings are [#20](https://github.com/drevendev/Devostasis/issues/20), [#21](https://github.com/drevendev/Devostasis/issues/21), [#22](https://github.com/drevendev/Devostasis/issues/22) and [#23](https://github.com/drevendev/Devostasis/issues/23), and two of them need vector kinds this repository has not built | -| B7 first outside consumer | integrating owner and elapsed observation time | repository selected; two dated shadow generations and consumer result qualified, production caller/schedule and sustained history remain | +| B7 first outside consumer | integrating owner and elapsed observation time | repository selected; production caller/schedule, sustained private history and independent feedback remain | | C1 GitLab adapter | **this repository, after B7 and the compatibility policy** | the requirements are accepted (`PV-GITLAB-003` by `PV-REV-GITLAB-003`, [#34](https://github.com/drevendev/Devostasis/issues/34)) | | C2 uncollected GitHub surfaces | this repository | each surface needs a contract decision first | | C3 Instruments | **this repository, after B7 and the compatibility policy** | the envelope, the carrier and four instruments are accepted ([#34](https://github.com/drevendev/Devostasis/issues/34)); the carrier moves the configuration and bundle contracts, which is why the policy comes first | @@ -325,12 +321,12 @@ names. ### B7. First outside repository integrates self-observation — the owner's move -The owner selected an outside GitLab repository. The 0.4/0.5 shadow pilots -qualified source handoff, an explicitly bound local caller, two actual dated -generations, a reported research result, disabled-caller control and portable -restore. The point remains independent consumer feedback over sustained time. -Protected production scheduling, private durable deployment and calibration -history are still open; the templates and two dates do not substitute for them. +The owner selected an outside GitLab repository. The 0.4/0.5 implementation +provides source handoff, an explicitly bound caller, operational records and +portable restore. Keep adopter qualification evidence private. The point remains +independent consumer feedback over sustained time. Protected production +scheduling, private durable deployment and calibration history are still open; +templates and local proofs do not substitute for them. ### Also open from the reporting review diff --git a/docs/external-adoption.md b/docs/external-adoption.md index 06b3e0a..4ca2077 100644 --- a/docs/external-adoption.md +++ b/docs/external-adoption.md @@ -46,22 +46,15 @@ claim, independent review, merge and deployment procedures. Source content cannot grant permission. Refresh immediately before an authorized action; an offline packet verifier does not prove current source state. -First external qualification, 2026-10-06: a private GitLab consumer's existing -scope-first adoption design supplied one declared research task. Its immutable -scope, slice and packet verified; exactly two files / 33,030 bytes were read -in 18 requests. A previously merged and deleted branch returned HTTP 404 -instead of being reused. Private evidence remains outside this public repository. -The result established a read-only shadow path, with no production activation. +The external shadow path supports a declared bounded research task, immutable +scope/slice/packet and a deleted-ref negative control. Store private source bytes +and detailed qualification results in the adopter's private issue/history. -Second external qualification, 2026-10-07: the explicitly bound observer -created a fresh canonical generation, verified a packet for the same two -paths in 19 requests and retained a packet-bound caller research result. -Disabled binding performed no network/canonical write. History including the -actual 2026-10-06 generation and operational records exported and restored -with equal audits: two actual UTC dates, not a claim of sustained calibration. -The full parallel inventory probe admitted 25 MRs / 163 requests / 165,300 ms; -coverage stayed PARTIAL after bridge response failures. No missing MR is -treated as absent, no exact-head approval is invented, and #62 remains open. +The 0.5 repeatable caller supports fresh bound generations, packet-bound caller +results, disabled controls and verified history restore. Keep detailed external +qualification and all inventory/timing/calendar evidence in the adopter's private +issue and history. Full bridge collection qualification remains #62; no missing +MR is treated as absent or exact-head approval invented. See [work-operations.md](work-operations.md) for the repeatable caller path. Remaining deployment acceptance: reviewed pinned release; existing credential From 4e81c1606b28cf1903d7143e3dc2bdb672d8099d Mon Sep 17 00:00:00 2001 From: Aleksei Bogun Date: Wed, 7 Oct 2026 16:25:09 +0300 Subject: [PATCH 20/33] Reconcile release readiness and run CI on contributor release pushes --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 2 ++ ROADMAP.md | 57 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c970db1..99d9be4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: ["master", "release/**"] + branches: ["master", "release/**", "zendreven/release-*"] pull_request: workflow_dispatch: diff --git a/CHANGELOG.md b/CHANGELOG.md index a0f3ed1..5ac6cfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ contract or a policy always come with a version bump of that contract. Consumer Operations, target E3. +- Run the CI matrix on contributor `zendreven/release-*` pushes and reconcile + the eight pending PRs, release sequence and next external-adoption milestone. - Explicit project/actor/policy digest/engine version/selection/budget binding, disabled by default; immutable COMPLETE/PARTIAL/FAILED/DISABLED invocation receipts outside canonical bundles. Wrong binding stops before network; diff --git a/ROADMAP.md b/ROADMAP.md index cf4c110..bf7923e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,7 +7,8 @@ started and work that cannot start. ## Release state and next steps — 2026-10-07 **0.5.0 Consumer Operations (E3) is implemented on -`zendreven/release-0.5.0`, stacked on [0.4 PR #64](https://github.com/drevendev/Devostasis/pull/64).** +`zendreven/release-0.5.0` in [PR #69](https://github.com/drevendev/Devostasis/pull/69), +stacked on [0.4 PR #64](https://github.com/drevendev/Devostasis/pull/64).** It provides explicit adopter project/policy/engine bindings, repeated canonical observation and immutable invocation receipts, bounded parallel collection, packet-bound caller outcomes, full private-history audit/export/restore and @@ -28,7 +29,55 @@ and accepted Phase C carriers after their existing gates. Private fleet billing upstream release-branch automation (#63) and incremental archive scale (#67) remain explicit. E3 closes the implemented operations path, not those gates. -The 0.4/0.3 readiness snapshots below are retained as historical context. +The audit of 2026-10-07 confirmed a clean initial working tree, 22 pending +commits beyond released `master`, and eight open, conflict-free PRs. All local +and remote development heads are ancestors of the 0.5 head; there is no +separate unintegrated branch to salvage. Latest published tag remains `v0.1.9`; +none of `v0.2.0` through `v0.5.0` exists. The 0.5 source head `aa642b3` passed +the complete 2,307-test suite locally on Python 3.13.13, all 148 CLI vectors, +current/frozen core and work verification, and work replay. Its +[fork matrix](https://github.com/abogun-product/Devostasis/actions/runs/37597164266) +passed on Python 3.12/3.13/3.14; its +[upstream run](https://github.com/drevendev/Devostasis/actions/runs/37597163077) +requires maintainer approval, and independent review remains pending. +Later commits require their own green CI; pushes to `zendreven/release-*` now +start that matrix automatically in the fork. + +### Next delivery sequence + +| Step | Deliverable | Completion evidence / blocker | +| --- | --- | --- | +| Release the implemented increments | #57 into upstream `release/0.2.0`, then #53, #59, #64 and #69 into `master`, with each release tag | Maintainer workflow approval, independent review and green checks on each final head; carried maintenance PRs #36/#37/#50 are reconciled when their commits land | +| Activate the selected outside consumer | Install a reviewed exact pin, project/policy binding, protected runner, durable writer and regular observation schedule | Actual private deployment receipts and verified stored generations; a recipe or shadow probe alone does not complete B7 | +| Qualify useful repeated operation | Complete large-project GitLab collection (#62), recover private fleet observation (#32), and collect independent consumer feedback | Repeated complete inventories, explicit partial/failure recovery, real calendar-day history and one completed bounded task with original acceptance evidence | +| Close the remaining assurance gaps | B1 exact conformance (#20/#23), timestamp/lineage/store audit families (#35), and GitLab exact-head approvals where needed (#58) | Accepted fixtures at the named boundary and explicit capability qualification; 62 named conformance cases remain without executable proof | +| Extend reach after the gates | Core GitLab Vital adapter and accepted TestState/Coverage/Deployment/Work Instrument carrier | B7 and compatibility gates, selected adopter needs, preserved historical replay; the existing GitLab work collector is a separate companion | + +**The highest-impact next major increment is operational adoption and a +measured feedback loop for the implemented Evidence to Action path.** Propose +this as the next product milestone, without assigning a release number or +changing the permanent B7/v1.0 acceptance. One outside repository should run +the pinned caller on a real schedule, retain and restore its verifiable private +history, and have an independent developer/agent complete work from a verified +scope and packet. Record requests, bytes, elapsed time, recovery causes and +consumer feedback in that private deployment. Exercise a moved-source negative +control and archive restore; keep reported completion separate from independent +acceptance. Regular observations must add calendar history, not just duplicate +bundle counts. That evidence determines which Phase C instrument is useful next +and whether the current bounded handoff actually saves repository rediscovery. +It does not by itself establish predictive calibration or close all Phase B +and 1.0 gates. + +Upstream Dependabot still names an absent `release/0.3.0`; #63 requires the +maintainer to establish the actual upstream release branch and route updates +there. The fork push-filter repair does not resolve that upstream deployment +choice. Long-term incremental history transfer remains #67; current whole-store +caps fail explicitly and do not prune private evidence. + +### Historical 0.4/0.3 readiness snapshots + +The snapshots below retain earlier release context; the current sequence and +qualification boundaries are those above. **0.4.0 Reproducible consumer handoff (E2) is now implemented on `zendreven/release-0.4.0`.** It carries the pending 0.2/0.3 ancestry and adds @@ -60,13 +109,15 @@ outstanding development branch already has a pull request: | 0.2.0 accepted Vital/history adoption | [#53](https://github.com/drevendev/Devostasis/pull/53) | Open; targets `master` | | 0.2.0 history admission/readiness repair | [#57](https://github.com/drevendev/Devostasis/pull/57) | Open; targets `release/0.2.0`, before #53 | | 0.3.0 Evidence to Action | [#59](https://github.com/drevendev/Devostasis/pull/59) | Open; includes the original commits of #53 and #57 | +| 0.4.0 reproducible consumer handoff | [#64](https://github.com/drevendev/Devostasis/pull/64) | Open; includes #59 and earlier pending commits | +| 0.5.0 consumer operations | [#69](https://github.com/drevendev/Devostasis/pull/69) | Open; includes #64 and all earlier development heads | | Dependabot, CI concurrency, Hungry Crab state | [#36](https://github.com/drevendev/Devostasis/pull/36), [#37](https://github.com/drevendev/Devostasis/pull/37), [#50](https://github.com/drevendev/Devostasis/pull/50) | Original commits included in #57 and #59; both attribution receipts/notices retained | The release heads are mergeable. Fork CI passed for the prepared heads on Python 3.12/3.13/3.14; upstream runs for #57/#59 require maintainer approval, and neither PR has an independent review yet. A later push needs fresh CI for its own head. Inclusion in a release branch is not an upstream merge: -all six PRs remain open and neither `v0.2.0` nor `v0.3.0` exists. +all eight PRs remain open and no `v0.2.0` through `v0.5.0` tag exists. **0.3.0 Evidence to Action is implemented on this release branch**, with its own consumer contract and target E1. The companion supplies five queues, From 09d497f16520b6dc20f5dd42077ff76774e1f5e1 Mon Sep 17 00:00:00 2001 From: Unisphere Dev Date: Thu, 8 Oct 2026 15:31:42 +0300 Subject: [PATCH 21/33] Add offline repository analysis and verified source-to-work handoff --- .devostasis/debt.json | 8 + .devostasis/targets.json | 5 + .github/workflows/ci.yml | 8 + .github/workflows/observe-self.yml | 4 +- .github/workflows/work-observe.yml | 2 +- .github/workflows/work-operations.yml | 2 +- CHANGELOG.md | 25 ++ README.md | 20 +- ROADMAP.md | 32 +- docs/code-analysis.md | 91 ++++++ docs/deployment.md | 4 +- docs/spec/PROVENANCE.md | 1 + docs/spec/README.md | 1 + docs/spec/code-analysis.md | 159 ++++++++++ docs/spec/conformance.md | 39 +++ docs/spec/work-scope.md | 5 + examples/code/bundle/analysis.json | 1 + examples/code/bundle/findings.sarif | 1 + examples/code/bundle/inputs.json | 1 + examples/code/bundle/manifest.json | 1 + examples/code/bundle/policy.json | 1 + examples/code/bundle/report.md | 52 ++++ examples/code/evidence.json | 1 + examples/code/inventory.json | 1 + examples/code/packet.json | 1 + examples/code/policy.json | 1 + examples/code/work-bundle/evidence.json | 1 + examples/code/work-bundle/inventory.json | 1 + examples/code/work-bundle/manifest.json | 1 + examples/code/work-bundle/policy.json | 1 + examples/code/work-bundle/report.md | 15 + examples/code/work-bundle/scope.json | 1 + examples/code/work-policy.json | 1 + examples/work/adoption.json | 2 +- examples/work/github-observe.yml | 4 +- examples/work/gitlab-observe.yml | 2 +- examples/work/gitlab-operations.yml | 2 +- pyproject.toml | 4 +- schemas/code-manifest.schema.json | 104 +++++++ schemas/code-packet.schema.json | 138 +++++++++ schemas/code-policy.schema.json | 148 +++++++++ scripts/make_code_examples.py | 69 +++++ src/devostasis/__init__.py | 2 +- src/devostasis/cli.py | 2 + src/devostasis/codeanalysis/__init__.py | 7 + src/devostasis/codeanalysis/bundle.py | 228 ++++++++++++++ src/devostasis/codeanalysis/cli.py | 72 +++++ src/devostasis/codeanalysis/consumer.py | 90 ++++++ src/devostasis/codeanalysis/engine.py | 210 +++++++++++++ src/devostasis/codeanalysis/git.py | 206 ++++++++++++ src/devostasis/workscope/collect.py | 36 +++ tests/test_code_analysis.py | 378 +++++++++++++++++++++++ tests/test_work_pagination.py | 54 ++++ 53 files changed, 2227 insertions(+), 19 deletions(-) create mode 100644 docs/code-analysis.md create mode 100644 docs/spec/code-analysis.md create mode 100644 examples/code/bundle/analysis.json create mode 100644 examples/code/bundle/findings.sarif create mode 100644 examples/code/bundle/inputs.json create mode 100644 examples/code/bundle/manifest.json create mode 100644 examples/code/bundle/policy.json create mode 100644 examples/code/bundle/report.md create mode 100644 examples/code/evidence.json create mode 100644 examples/code/inventory.json create mode 100644 examples/code/packet.json create mode 100644 examples/code/policy.json create mode 100644 examples/code/work-bundle/evidence.json create mode 100644 examples/code/work-bundle/inventory.json create mode 100644 examples/code/work-bundle/manifest.json create mode 100644 examples/code/work-bundle/policy.json create mode 100644 examples/code/work-bundle/report.md create mode 100644 examples/code/work-bundle/scope.json create mode 100644 examples/code/work-policy.json create mode 100644 schemas/code-manifest.schema.json create mode 100644 schemas/code-packet.schema.json create mode 100644 schemas/code-policy.schema.json create mode 100644 scripts/make_code_examples.py create mode 100644 src/devostasis/codeanalysis/__init__.py create mode 100644 src/devostasis/codeanalysis/bundle.py create mode 100644 src/devostasis/codeanalysis/cli.py create mode 100644 src/devostasis/codeanalysis/consumer.py create mode 100644 src/devostasis/codeanalysis/engine.py create mode 100644 src/devostasis/codeanalysis/git.py create mode 100644 tests/test_code_analysis.py create mode 100644 tests/test_work_pagination.py diff --git a/.devostasis/debt.json b/.devostasis/debt.json index b607e08..78883ee 100644 --- a/.devostasis/debt.json +++ b/.devostasis/debt.json @@ -2,6 +2,14 @@ "schema": "devostasis.debt.v1", "notes": "Registered maintenance obligations of this repository, read by Devostasis when debt.source = file. An item belongs here when the current code carries a cost, not when a feature is missing: planned work lives in targets.json and ROADMAP.md. 'opened' is the date the obligation started being carried, 'updated' the last time it changed; an open item untouched for 30 days is stale.", "items": [ + { + "id": "D-10", + "title": "Native change hotspots and local roots rely on recorded acquisition rather than complete ancestry proofs", + "state": "open", + "opened": "2026-10-08", + "updated": "2026-10-08", + "cost": "0.6 source proofs bind pinned commit/tree and admitted blob bytes, but replay of recorded history counts/root traversal is not independent admission of their complete ancestry. A rehashed recorded history can alter hotspots. Counts remain descriptive and separately covered, with no health or acceptance authority. Bounded historic object/parent proof and preserved v1 replay are issue #73." + }, { "id": "D-7", "title": "Live candidate content recheck needs an explicit mutable source-ref binding", diff --git a/.devostasis/targets.json b/.devostasis/targets.json index 780f7be..df31072 100644 --- a/.devostasis/targets.json +++ b/.devostasis/targets.json @@ -67,6 +67,11 @@ "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" }, + { + "id": "E4", + "title": "Offline Repository Analysis: pinned source proof, Python evidence, conservative revision deltas and analyze_code handoff", + "state": "closed" + }, { "id": "v1.0", "title": "Release 1.0: compatibility policy per contract, a second provider, a calibration corpus large enough to argue a threshold", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99d9be4..c94bd7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,14 @@ jobs: assert a['bundle_id'] == b['bundle_id'] subprocess.run(['devostasis', 'work', 'verify', '--bundle', a['path']], check=True) PY + - name: Native source proof and analysis-to-work example + run: | + devostasis code verify --bundle examples/code/bundle + devostasis code verify-packet --bundle examples/code/bundle --packet examples/code/packet.json + devostasis work verify --bundle examples/code/work-bundle + devostasis work replay --bundle examples/code/work-bundle + python scripts/make_code_examples.py + git diff --exit-code -- examples/code - name: Determinism check on the example observations run: | a=$(devostasis build --observations examples/observations.json --store /tmp/store-a --planning milestones --debt-label type:refactor --debt-mapping-version example-1 | head -n 1 | awk '{print $2}') diff --git a/.github/workflows/observe-self.yml b/.github/workflows/observe-self.yml index f663fa9..7b31ce3 100644 --- a/.github/workflows/observe-self.yml +++ b/.github/workflows/observe-self.yml @@ -4,7 +4,7 @@ # # jobs: # vitals: -# uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.5.0 +# uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.6.0 # with: # debt-labels: "type:debt" # @@ -24,7 +24,7 @@ on: devostasis-ref: description: Git ref of Devostasis to install (tag, branch or commit) type: string - default: v0.5.0 + default: v0.6.0 planning-source: description: milestones, file or none type: string diff --git a/.github/workflows/work-observe.yml b/.github/workflows/work-observe.yml index 16a8dae..3ecb8f8 100644 --- a/.github/workflows/work-observe.yml +++ b/.github/workflows/work-observe.yml @@ -5,7 +5,7 @@ on: inputs: devostasis-ref: type: string - default: v0.5.0 + default: v0.6.0 policy-path: type: string default: .devostasis/work-policy.json diff --git a/.github/workflows/work-operations.yml b/.github/workflows/work-operations.yml index 545bb44..3d3fc37 100644 --- a/.github/workflows/work-operations.yml +++ b/.github/workflows/work-operations.yml @@ -4,7 +4,7 @@ on: inputs: devostasis-ref: type: string - default: v0.5.0 + default: v0.6.0 binding-path: type: string default: .devostasis/work-adoption.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ac6cfd..193c949 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,31 @@ 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.6.0 (unreleased) + +Offline Repository Analysis, target E4, issue #72. + +- New stdlib-only `code` companion: immutable local Git snapshot, Python 3.12 + AST structure, six scoped findings, declared-module import cycles and bounded + recent non-merge change hotspots. No source execution or forge dependency. +- Canonical source-proof bundles bind admitted blobs and the entire tree to a + pinned SHA-1/SHA-256 commit, then replay JSON, SARIF and escaped Markdown + byte for byte offline. Unsupported/excluded/malformed/capped inputs and shallow + history retain explicit states; no absence or health claim is inferred. +- Conservative revision comparison, stable finding IDs and bounded exact-source + packets with offline verification. Native SARIF feeds existing analyze_code + queues only through an admitted same-revision forge inventory, preserving + partial coverage and original observation age. +- Public contract, manifest/policy/packet schemas, fully synthetic analysis and + work examples, regeneration script and Python 3.12/3.13/3.14 CI checks. +- Reject contradictory GitLab page/total headers and malformed or changing + GitHub totals while preserving observed partial rows (#70). +- Isolate inherited Git context and unrelated credential environment variables + from local analysis subprocesses and synthetic diagnostics (#74). +- Independent ancestry/count proof remains documented debt (#73); external + scheduling/adoption is deferred (#71). Existing Vitals, core/work replay, + B1/B7 and upstream release/review gates retain their contracts. + ## 0.5.0 (unreleased) Consumer Operations, target E3. diff --git a/README.md b/README.md index 3287e77..31ee0a3 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ The name is *development* plus *homeostasis*: the goal is a stable, honest reading of where a project actually stands, so that people and autonomous development systems can react to it. -The latest published tag is **v0.1.9**. This checkout prepares **0.5.0**, -including the pending 0.2.0/0.3.0/0.4.0 increments; these are not released yet. +The latest published tag is **v0.1.9**. This checkout prepares **0.6.0**, +including the pending 0.2.0/0.3.0/0.4.0/0.5.0 increments; these are not released yet. Use the checkout installation below to try the new companion before its tag is published. See [ROADMAP.md](ROADMAP.md) for release gates and next steps. @@ -42,6 +42,18 @@ collection under one shared budget, private history audit/export/restore and packet-bound caller results. Start with the [operations guide](docs/work-operations.md). Calendar days and evidence gaps stay visible; caller reports retain their own authority. +**0.6.0 Offline Repository Analysis** reads a pinned local Git revision, +parses Python structure, records static import cycles and recent change hotspots, +and publishes an exact offline replay bundle with SARIF. Compare revisions, +extract bounded original-source packets and feed findings into `analyze_code` +without an external service. See the [analysis guide](docs/code-analysis.md). + +```sh +devostasis code analyze --repo . --revision HEAD --output /private/code-analysis +devostasis code verify --bundle /private/code-analysis +devostasis code verify --bundle examples/code/bundle +``` + ```sh devostasis work policy --actor YOUR_LOGIN --output policy.json devostasis work run --repo OWNER/REPO --policy policy.json --store /private/work-history @@ -202,7 +214,7 @@ pip install -e . After the 0.5.0 release tag is published: ```bash -pip install git+https://github.com/drevendev/devostasis@v0.5.0 +pip install git+https://github.com/drevendev/devostasis@v0.6.0 ``` Observe one repository (a GitHub token is read from `DEVOSTASIS_GITHUB_TOKEN`, @@ -261,7 +273,7 @@ permissions: jobs: vitals: - uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.5.0 + uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.6.0 decide: needs: vitals runs-on: ubuntu-latest diff --git a/ROADMAP.md b/ROADMAP.md index bf7923e..eff266e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,37 @@ 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-07 +## Release state and next steps — 2026-10-08 + +**0.6.0 Offline Repository Analysis (E4) is prepared on +`zendreven/release-0.6.0`, carrying the pending 0.2–0.5 ancestry.** +The owner deferred external deployment and requested a complete code/analysis +increment. This release adds native pinned Git source evidence, Python structure +and static import cycles, bounded change hotspots, offline source-proof replay, +SARIF, conservative revision comparison and verified source packets. Findings +feed the existing analyze_code queue through explicit same-revision inventory +admission. See [code-analysis.md](docs/code-analysis.md) and issue #72. + +Next: review the final 0.6 release PR and green contributor matrix, then have the +maintainer reconcile and release the stacked increments under the repository's +release process. B1's remaining exact vectors (#23), sustained outside adoption +B7, core GitLab/accepted Instrument gates and maintainer branch automation (#63) +remain open. No unpublished tag is asserted available. E4 is implementation +completion, without external deployment or independent acceptance claims. + +Debt found during this increment: contradictory pagination completeness (#70, +fixed with regressions), and independently verifiable history/root/count proof +(#73, currently recorded acquisition observations). Protected scheduled consumer +adoption (#71) is deferred by the owner. Inherited local Git context and excess +credential environment forwarding were isolated and regression-tested (#74). +Native language expansion and calibrated +instrument judgement should follow concrete source/consumer needs and accepted +contracts; the current version explicitly scopes Python static evidence. + +## Historical 0.5 readiness audit — 2026-10-07 + +The operational-adoption priority below records the previous audit. It is +superseded for this increment by the owner's code-only scope above. **0.5.0 Consumer Operations (E3) is implemented on `zendreven/release-0.5.0` in [PR #69](https://github.com/drevendev/Devostasis/pull/69), diff --git a/docs/code-analysis.md b/docs/code-analysis.md new file mode 100644 index 0000000..ba700b8 --- /dev/null +++ b/docs/code-analysis.md @@ -0,0 +1,91 @@ +# Offline Repository Analysis + +The 0.6.0 companion turns a local Git revision into reproducible Python source +evidence. It inventories the entire committed tree, parses in-policy Python +files, records function decision counts and spans, resolves declared local +imports, detects static import cycles and lists recent change hotspots. +Repository code, hooks, tests and dependencies are never executed by analysis. +It uses the standard library and an installed Git supporting `--no-lazy-fetch`. +Python 3.12 is the fixed analysis grammar. + +Install the checkout with `python -m pip install -e .`. Select the revision +explicitly; uncommitted work is excluded. Generate and edit a policy to declare +source roots, include/exclude prefixes, local thresholds and read limits: + +```sh +devostasis code policy --output code-policy.json +devostasis code analyze --repo . --revision HEAD --policy code-policy.json --output /private/code-analysis +devostasis code verify --bundle /private/code-analysis +devostasis code show --bundle /private/code-analysis +devostasis code sarif --bundle /private/code-analysis --output /private/findings.sarif +``` + +The default observation time is the commit's committer time, so repeating the +same command at the same revision is reproducible. `--at` explicitly selects a +UTC history anchor. The source tree remains the pinned revision, even when +that anchor precedes its commit time. History includes only non-merge ancestors +inside the recorded 28-day window. A commit/time/read cap or shallow checkout +retains an explicit history gap. Hotspot counts under PARTIAL history are +observed lower counts, with no productivity or health judgement. + +The bundle contains `inputs.json`, `policy.json`, `analysis.json`, +`findings.sarif`, `report.md` and `manifest.json`. Verification needs no Git +checkout or network: it binds admitted bytes to their Git blob IDs, binds the +complete tree inventory to the commit, and replays every output byte. Commit +metadata and source bytes are included; keep real analysis bundles private +when those inputs are private. The public example is entirely synthetic: + +```sh +devostasis code verify --bundle examples/code/bundle +``` + +Python coverage is COMPLETE, PARTIAL or NOT_APPLICABLE. Other languages remain +UNSUPPORTED; exclusions, syntax failures, encoding failures, links/submodules +and budget omissions retain distinct file states. COMPLETE means analysis of +the admitted Python scope, with the recorded static resolution assumptions. +It does not prove runtime behavior or the absence of defects in other languages. +The local identity derives from the recorded root commit IDs, not a forge URL; +shallow or unrelated roots and changed policy make comparisons INCOMPARABLE. +History counts and root traversal are acquisition observations; their complete +ancestry is not independently proved by the source tree proof (issue #73). + +Compare two verified bundles under the same policy: + +```sh +devostasis code compare --before /private/code-before --after /private/code-after --output /private/code-delta.json +``` + +A disappeared finding is RESOLVED only when every involved file is analyzed +or positively absent from the proved tree. An unavailable file gives +UNOBSERVED. Newly visible findings behind prior gaps are FIRST_OBSERVED. Line +moves retain finding identity and report CHANGED. These are finding transitions, +without an aggregate improvement judgement. + +Create a bounded exact-source packet for a selected set of admitted files: + +```sh +devostasis code packet --bundle /private/code-analysis --path src/app.py --max-files 5 --max-bytes 65536 --output /private/code-packet.json +devostasis code verify-packet --bundle /private/code-analysis --packet /private/code-packet.json +``` + +Packets retain original bytes, blob IDs, subject, bundle identity and declared +limits. They execute nothing and grant no permission or freshness guarantee. + +To feed findings into the existing `analyze_code` queue, supply an already +admitted work inventory collected for the *same full commit ID*: + +```sh +devostasis code work-evidence --bundle /private/code-analysis --inventory /private/inventory.json --output /private/code-evidence.json +devostasis work build --inventory /private/inventory.json --policy /private/work-policy.json --evidence /private/code-evidence.json --store /private/work-history +``` + +The inventory supplies immutable forge identity explicitly. Export preserves +the analysis time and marks incomplete source coverage PARTIAL; it cannot +re-date evidence. Existing work freshness, revision, dependency, acceptance and +read-budget rules still apply. Reanalyze with an explicit appropriate `--at` +to acquire a new observation, and recheck live sources before execution. +No external runner, schedule, credentials or deployment is required for the +offline commands. B1, B7 and release/review gates remain separate. + +See the [wire contract](spec/code-analysis.md) and +[synthetic report](../examples/code/bundle/report.md). diff --git a/docs/deployment.md b/docs/deployment.md index 9141395..d80e4e0 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -31,7 +31,7 @@ permissions: jobs: vitals: - uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.5.0 + uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.6.0 with: debt-labels: "type:debt" # optional: issue labels that mark debt items # planning-source: file # optional: targets register instead of milestones @@ -168,7 +168,7 @@ jobs: with: python-version: "3.12" - name: Install Devostasis - run: python -m pip install --quiet "git+https://github.com/drevendev/devostasis@v0.5.0" + run: python -m pip install --quiet "git+https://github.com/drevendev/devostasis@v0.6.0" - name: Observe every configured project id: run continue-on-error: true diff --git a/docs/spec/PROVENANCE.md b/docs/spec/PROVENANCE.md index 8443602..33692d3 100644 --- a/docs/spec/PROVENANCE.md +++ b/docs/spec/PROVENANCE.md @@ -8,6 +8,7 @@ normative here. The process is domain-neutral and documented in the | Identifier used by the code | Source unit(s) | Status when implemented | | --- | --- | --- | +| `devostasis.code-analysis.v1`, `devostasis.code-policy.v1`, `devostasis.code-engine.v1`, `devostasis.code-packet.v1`, `devostasis.code-comparison.v1` | implementation-owned E4, issue #72 | local, introduced in 0.6.0; static source evidence without accepted Vital/Instrument judgement; independent history proof is #73 | | `devostasis.work-adoption.v1`, `devostasis.work-invocation.v1`, `devostasis.work-result.v1`, `devostasis.work-history-archive.v1`, `devostasis.work-history-audit.v1` | implementation-owned E3 operational adoption, issues #62/#65 | local, introduced in 0.5.0; separate from immutable work/core canonical members and accepted Instrument/Vital semantics; caller reports grant no independent acceptance or execution authority | | `devostasis.bundle.v3`, `PV-BUNDLE-ID-003`, `PV-RECEIPT-IDENTITY-003`, `devostasis.receipt-identity.v1`, `devostasis.observations.v2`, `devostasis.manifest.v2`, `devostasis.execution-receipt.v1`, `devostasis.render.v5` | cumulative PV-RECEIPT-IDENTITY-001/002/003 | independently accepted by PV-REV-RECEIPT-IDENTITY-003; adopted in 0.4.0 with RECEIPT-ID-01..15,17..21. Literal devostasis schema tokens are local choices; rejected ID-16 is preserved, not reassigned | | `devostasis.contract-compatibility.v1`, `devostasis.compatibility-dispatch-policy.v1` | PV-COMPAT-001/002 | independently accepted by PV-REV-COMPAT-002; adopted in 0.4.0; no accepted production nonidentity edge or migration is declared | diff --git a/docs/spec/README.md b/docs/spec/README.md index 2dc9c41..b51a2c8 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -24,6 +24,7 @@ conformance tests. | [compatibility.md](compatibility.md) | Exact tuples, operation-scoped edges, immutable policy and replay | | [work-handoff.md](work-handoff.md) | Work v2 mutable sources, bounded pinned source packets and offline verification | | [work-operations.md](work-operations.md) | Adoption binding, invocation audit, caller reports and private history transfer | +| [code-analysis.md](code-analysis.md) | Native pinned source proofs, Python analysis, history, deltas, packets and work export | | [PROVENANCE.md](PROVENANCE.md) | Contract identifiers and the research units they come from | Vocabulary used throughout: diff --git a/docs/spec/code-analysis.md b/docs/spec/code-analysis.md new file mode 100644 index 0000000..433a6b0 --- /dev/null +++ b/docs/spec/code-analysis.md @@ -0,0 +1,159 @@ +# Offline code analysis contract + +Implementation-owned companion, introduced in 0.6.0. No accepted research +Vital rule, ordering or Work Instrument judgement changes. Its identifiers are +`devostasis.code-analysis.v1`, `devostasis.code-policy.v1`, +`devostasis.code-engine.v1`, `devostasis.code-packet.v1` and +`devostasis.code-comparison.v1`. Case IDs below use the local DEV-CODE family. + +## Admission and collection + +Policy is a closed object: `contract`, `version`, `grammar`, `include`, +`exclude`, `source_roots`, `budget`, `limits`, `history`. Grammar is +PYTHON_3_12; paths are unique relative prefixes, with `.` allowed only as a +source root. Longest matching source root gives the declared Python module. +Budgets contain `max_files` (1..5000), `max_file_bytes` (1..4194304), +`max_total_bytes` (1..67108864, at least the per-file cap), and `seconds` +(1..3600). Limits contain `complexity`, `function_lines`, `file_lines` +(1..100000). History contains explicit boolean `enabled`, `days` (1..3650) +and `max_commits` (1..10000). Defaults are in `code policy`; thresholds are +local source triage policy, not calibrated health bands. Unknown fields, +boolean integers, unsafe paths and unbounded values are rejected. +The seconds deadline covers Git acquisition; offline replay is bounded by +source/object/bundle sizes and does not invoke subprocesses. + +`inputs.json` is a closed object with `subject`, `observed_at`, `entries`, +`sources`, `history`, `proof`. Subject contains `kind: LOCAL_GIT`, +`object_format: sha1|sha256`, sorted unique `roots`, `repository_id`, full +`revision` and `tree`. Repository identity hashes object format and locally +observed roots; it is not an authenticated forge/project identity. Observation +timestamps use the existing UTC timestamp contract. Collection resolves the +revision once and uses only immutable IDs thereafter, with replacements, lazy +fetch, filesystem monitors, external diff and text conversion disabled. Source +acquisition does not inspect index/worktree modifications or execute hooks. +Git processes receive only required OS/path/home/locale environment and explicit +read controls; inherited Git directory/config injection and unrelated credential +variables cannot override the selected repository (#74). +Output/time/object caps fail explicitly rather than publish an empty success. + +Each entry contains decoded safe `path` or null, canonical `path_base64`, +Git `mode`, `kind`, `object_id`, `size` (null only for a gitlink), `status` and +`reason`. Entries sort by raw path bytes and cover every leaf in the proved +tree. In-policy regular `.py` blobs within all budgets are ADMITTED. Excluded +prefixes are OUT_OF_SCOPE/POLICY_EXCLUSION; other regular languages are +UNSUPPORTED/LANGUAGE_UNSUPPORTED. UNAVAILABLE reasons are UNSAFE_PATH, +SOURCE_LINK_OR_SUBMODULE, FILE_BYTE_CAP, FILE_COUNT_CAP and TOTAL_BYTE_CAP. +`sources` maps exactly admitted paths to canonical base64 of unmodified bytes. + +`proof` contains the raw base64 commit object and an object-ID-to-base64 map +of every reachable tree. Verification computes framed Git object hashes, +checks the commit/tree link, expands all trees, checks the entire inventory and +hashes every admitted blob. Tree proofs are bounded to 8 MiB and 10000 objects, +commit proof to 1 MiB, expanded leaves to 100000. Missing/extra/duplicate tree, +path or source data is rejected. SHA-1/SHA-256 Git format is explicit. This +establishes a commitment to source bytes, not authenticated commit authorship. + +History contains `status`, unique `reasons`, `start`, `end`, `commits`. The +window is inclusive, ends at observed_at and spans policy days. COMPLETE means +the bounded collector exhausted its selected non-merge ancestor walk; +PARTIAL retains admitted rows and cap/shallow/path gaps; UNAVAILABLE admits no +commits; NOT_REQUESTED requires history disabled and admits no commits. +Each commit has `revision`, `committed_at`, `changes`; changes contain `path`, +`added`, `deleted` (both null for binary changes). Changes sort by path, +commits by timestamp/full ID, with no duplicates. Counts, traversal roots and +history completeness are recorded acquisition claims, not proved ancestry. +Offline verification replays those observations; proof hardening is #73. + +## Derived source evidence + +`analysis.json` contains `engine`, `subject`, `observed_at`, `coverage`, +`files`, `findings`, `imports`, `import_cycles`, `hotspots`, `history` and +`authority`. Coverage is PARTIAL if any source is UNAVAILABLE or PARSE_ERROR; +otherwise COMPLETE if Python inputs are applicable, else NOT_APPLICABLE. +Unsupported languages/exclusions remain visible beside that scoped coverage. +History coverage is independent and does not disappear behind source coverage. + +File rows contain `path`, `object_id`, `status`, `reason`, `module`, `lines`, +`functions`. Admitted Python uses its declared encoding cookie/BOM; +unavailable encoding becomes SOURCE_ENCODING_UNAVAILABLE. AST parsing at +grammar 3.12 yields ANALYZED or PARSE_ERROR/PYTHON_3_12_SYNTAX. Lines are +decoded splitlines count; unavailable/unsupported inputs retain null metrics. +Function records contain qualified `symbol`, `line`, `end_line`, inclusive +`lines`, `complexity`, `async`. Repeated symbols receive deterministic #2 etc. +Decision count starts at one and adds if/for/async-for/while/if-expression/ +exception handlers, boolean operand count minus one, comprehension generators +and filters, and match case count minus one. Nested functions/classes/lambdas +are excluded from the enclosing body count, as are signature defaults, +annotations and decorators. This is a named syntactic count; +decorators, runtime dispatch and path reachability are not evaluated. + +Rules: DS-PY-SYNTAX, DS-PY-COMPLEXITY, DS-PY-FUNCTION-SIZE, DS-PY-FILE-SIZE, +DS-PY-BARE-EXCEPT and DS-PY-IMPORT-CYCLE. Size/count rules trigger strictly +above configured limits; bare except catches BaseException. Findings contain +`id` (digest of rule/path/qualified symbol), `rule`, `level`, `path`, `symbol`, +`line`, `end_line`, fixed `message`, rule-specific `evidence`, `related_paths`. +Syntax is error, other rules warning. Syntax/file findings use ; +bare exceptions use qualified traversal ordinals. Cycle symbol hashes its +sorted path component. Findings sort by path/line/rule/symbol. + +Import rows contain `from`, `to`, `module`, `line`, `status`, `candidates`. +Only a unique declared module with analyzed source is RESOLVED. Multiple +roots are AMBIGUOUS, missing syntax/bytes UNAVAILABLE, other names +EXTERNAL_OR_UNRESOLVED. Relative imports outside the declared package remain +unresolved. Strongly connected components of RESOLVED edges form sorted +`import_cycles`, including self edges. These are static graph cycles, not +proof of runtime initialization failure; dynamic imports and package export +semantics are not executed. Implicit parent imports are not inferred. + +Hotspots contain current in-scope `path`, number of observed distinct +non-merge `revisions`, summed `added`, `deleted`, `binary_changes` and history +`coverage`; sort descending revisions, then added+deleted, then path. Current +non-Python files can appear with change evidence without invented AST metrics. +Analysis history retains input status/reasons/start/end, omitting commit rows. +Authority is STATIC_SOURCE_EVIDENCE, without execution, independent acceptance, +health score or merge authority. + +## Artifacts and consumers + +Exactly six members: inputs.json, policy.json, analysis.json, findings.sarif, +report.md, manifest.json. JSON uses the existing canonical byte contract. +Manifest contains contract, engine, subject, member byte digests and bundle_id +(digest of the manifest without that ID). Total bundle cap is 128 MiB. +Verification recomputes source admission, derived JSON, SARIF and Markdown, +then requires every byte to match. Merely updating outer hashes is insufficient. +Bundle destinations are immutable, reject links and foreign/changed contents, +and publish atomically under a parent lock. Matching content is idempotent. + +SARIF 2.1.0 retains every finding, rule, source revision, stable fingerprint, +physical path/line/span and source/history coverage. Markdown escapes source +strings and displays up to 100 findings and 20 hotspots with visible totals. +It grants no freshness, live-state or acceptance authority. + +Comparisons contain contract, before/after bundle IDs, status, reasons, +findings, files, authority. Different local identity or exact policy body gives +INCOMPARABLE. Finding rows contain id/state/before/after: PERSISTING for equal +records, CHANGED for altered records sharing ID, NEW/FIRST_OBSERVED when new, +RESOLVED/UNOBSERVED when absent. NEW/RESOLVED require every involved path on +the opposite side to be ANALYZED or absent from the complete proved inventory; +other statuses yield the conservative alternative. File transitions retain +path/state/before_status/after_status with ADDED/REMOVED/CHANGED/UNCHANGED +based on tree presence and blob ID. No aggregate ordering is inferred. + +Packets contain contract, analysis_bundle_id, subject, budget, files, +total_bytes, authority, packet_id. Budget has max_files (1..1000) and +max_bytes (1..67108864); files are nonempty unique sorted admitted paths with +path/object_id/size/base64, preserving whole blobs. Verification reconstructs +the packet against its verified bundle. No metadata truncation, duplicated +path, relabelled authority or changed bytes are admitted. + +Work export requires an admitted forge inventory and exact full revision +equality. It supplies sarif.v1 under the existing evidence contract, preserves +the source observation time and carries PARTIAL for any non-COMPLETE Python +coverage. Standalone SARIF encodes relative URI references; work export retains +literal repository-relative paths as required by the historical sarif.v1 +profile, including filenames with spaces or URI metacharacters. An optional +export time may only equal the recorded time. Consumer +freshness and source/subject admission still run; native code evidence proposes +analyze_code work without closing acceptance, B1, B7 or instrument gates. +Public manifest/policy/packet schemas accompany the executable admission +rules. Local conformance citations are in [conformance.md](conformance.md). diff --git a/docs/spec/conformance.md b/docs/spec/conformance.md index 2fb77fe..98e78b8 100644 --- a/docs/spec/conformance.md +++ b/docs/spec/conformance.md @@ -608,3 +608,42 @@ do not close the outstanding T3/T6 or core bundle/store vector carrier gaps. DEV-OPS regression evidence introduces no research case identifiers and does not close B1's missing accepted exact fixtures or B7's sustained calibration. + +## Native offline repository analysis (implementation-local DEV-CODE) + +| Case | Meaning | Test | +| --- | --- | --- | +| DEV-CODE-01 | real git to byte identical offline bundle without source execution | `test_code_real_git_to_byte_identical_offline_bundle_without_source_execution` | +| DEV-CODE-02 | rehashed or changed bundle cannot bypass source proof and replay | `test_code_rehashed_or_changed_bundle_cannot_bypass_source_proof_and_replay` | +| DEV-CODE-03 | read caps are explicit and never become empty success | `test_code_read_caps_are_explicit_and_never_become_empty_success` | +| DEV-CODE-04 | syntax encoding unsupported and policy exclusion have distinct states | `test_code_syntax_encoding_unsupported_and_policy_exclusion_have_distinct_states` | +| DEV-CODE-05 | non utf8 python cookie is analyzed without rewriting blob bytes | `test_code_non_utf8_python_cookie_is_analyzed_without_rewriting_blob_bytes` | +| DEV-CODE-06 | git link and submodule are not followed | `test_code_git_link_and_submodule_are_not_followed` | +| DEV-CODE-07 | import ambiguity and missing target never form proven cycles | `test_code_import_ambiguity_and_missing_target_never_form_proven_cycles` | +| DEV-CODE-08 | nested function decisions are not double counted and finding ids survive line moves | `test_code_nested_function_decisions_are_not_double_counted_and_finding_ids_survive_line_moves` | +| DEV-CODE-09 | partial history counts are labeled and time window excludes future commits | `test_code_partial_history_counts_are_labeled_and_time_window_excludes_future_commits` | +| DEV-CODE-10 | missing source cannot resolve a finding and deleted source can | `test_code_missing_source_cannot_resolve_a_finding_and_deleted_source_can` | +| DEV-CODE-11 | changed policy or unrelated root identity is incomparable | `test_code_changed_policy_or_unrelated_root_identity_is_incomparable` | +| DEV-CODE-12 | offline source packets reject tampering and enforce declared limits | `test_code_offline_source_packets_reject_tampering_and_enforce_declared_limits` | +| DEV-CODE-13 | cli offline end to end and admitted work evidence | `test_code_cli_offline_end_to_end_and_admitted_work_evidence` | +| DEV-CODE-14 | repository strings are escaped in the report and foreign outputs are preserved | `test_code_repository_strings_are_escaped_in_the_report_and_foreign_outputs_are_preserved` | +| DEV-CODE-15 | policy rejects unknown or unbounded inputs | `test_code_policy_rejects_unknown_or_unbounded_inputs` | +| DEV-CODE-16 | no python scope is not applicable and syntax only scope is partial | `test_code_no_python_scope_is_not_applicable_and_syntax_only_scope_is_partial` | +| DEV-CODE-17 | shallow history retains explicit partial coverage | `test_code_shallow_history_retains_explicit_partial_coverage` | +| DEV-CODE-18 | sha256 repository proofs replay with explicit object format | `test_code_sha256_repository_proofs_replay_with_explicit_object_format` | +| DEV-CODE-19 | relative import outside declared package is unresolved | `test_code_relative_import_outside_declared_package_is_unresolved` | +| DEV-CODE-20 | git caps and option like revisions fail explicitly | `test_code_git_caps_and_option_like_revisions_fail_explicitly` | +| DEV-CODE-21 | work export preserves gaps and age and produces analysis queue | `test_code_work_export_preserves_gaps_and_age_and_produces_analysis_queue` | +| DEV-CODE-22 | frozen native/work examples and public schemas replay | `test_code_frozen_native_and_work_examples_replay_with_admitted_source_packet` | +| DEV-CODE-23 | SARIF URI encoding preserves literal historical work paths | `test_code_sarif_uri_encoding_and_legacy_work_paths_are_bound_to_real_filenames` | +| DEV-CODE-24 | definition-time decisions and undeclared root packages remain scoped | `test_code_function_counts_exclude_definition_time_decisions_and_missing_root_packages` | +| DEV-CODE-25 | inherited Git context and credentials cannot override source | `test_code_git_inherited_repository_and_secret_environment_cannot_override_selected_source` | + +## Pagination continuity repair (implementation-local DEV-PAGES, issue #70) + +| Case | Meaning | Test | +| --- | --- | --- | +| DEV-PAGES-01 | contradictory headers preserve observed partial items | `test_work_pagination_contradictory_headers_preserve_observed_partial_items` | +| DEV-PAGES-02 | changed total between pages is partial | `test_work_pagination_changed_total_between_pages_is_partial` | +| DEV-PAGES-03 | valid exact full empty and header absent responses are complete | `test_work_pagination_valid_exact_full_empty_and_header_absent_responses_are_complete` | +| DEV-PAGES-04 | github result caps and invalid totals are not complete | `test_work_pagination_github_result_caps_and_invalid_totals_are_not_complete` | diff --git a/docs/spec/work-scope.md b/docs/spec/work-scope.md index b69d00a..b7303f0 100644 --- a/docs/spec/work-scope.md +++ b/docs/spec/work-scope.md @@ -168,3 +168,8 @@ cursor binding, backfills, candidate isolation and failed history writes. The public fixture `examples/work/bundle` replays offline, regenerated by `scripts/make_work_examples.py`. Adoption and live checks are documented in [../work-scopes.md](../work-scopes.md). + +Pagination completeness additionally validates declared GitLab page/total +continuity, numeric next-page adjacency and terminal observed counts; malformed, +contradictory or changing metadata preserves PARTIAL/UNAVAILABLE. GitHub +result caps and malformed/changing totals cannot establish COMPLETE (#70). diff --git a/examples/code/bundle/analysis.json b/examples/code/bundle/analysis.json new file mode 100644 index 0000000..ae5c461 --- /dev/null +++ b/examples/code/bundle/analysis.json @@ -0,0 +1 @@ +{"authority":"STATIC_SOURCE_EVIDENCE; no execution, independent acceptance, health score or merge authority","coverage":"COMPLETE","engine":"devostasis.code-engine.v1","files":[{"functions":[],"lines":null,"module":null,"object_id":"243c57287062ba5fb0d050828cdbcc0b91b39aa0","path":"README.md","reason":"LANGUAGE_UNSUPPORTED","status":"UNSUPPORTED"},{"functions":[],"lines":0,"module":"widget","object_id":"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391","path":"src/widget/__init__.py","reason":null,"status":"ANALYZED"},{"functions":[{"async":false,"complexity":2,"end_line":6,"line":3,"lines":4,"symbol":"choose"}],"lines":6,"module":"widget.app","object_id":"619dd2aa0d86bec82bdc88f2bbfc166e233feeb7","path":"src/widget/app.py","reason":null,"status":"ANALYZED"},{"functions":[{"async":false,"complexity":2,"end_line":7,"line":3,"lines":5,"symbol":"fallback"}],"lines":7,"module":"widget.parser","object_id":"e18e086cd787854f50a4270afa266031f783529f","path":"src/widget/parser.py","reason":null,"status":"ANALYZED"}],"findings":[{"end_line":1,"evidence":{"paths":["src/widget/app.py","src/widget/parser.py"]},"id":"sha256:f360634dd4075ebb04d4c979fe5a321757b8489275c06350a86e6347d99f3fac","level":"warning","line":1,"message":"Static import cycle","path":"src/widget/app.py","related_paths":["src/widget/app.py","src/widget/parser.py"],"rule":"DS-PY-IMPORT-CYCLE","symbol":"cycle:d205d439f837a19cc3f0467afed21a795b586999d0c79f8d3c9fba70482306cc"},{"end_line":6,"evidence":{"complexity":2,"limit":1},"id":"sha256:e61175cd8aee0c21f82268ef4094ea2febe3cf8cbced21d8ea71213776003b36","level":"warning","line":3,"message":"Function decision count exceeds policy","path":"src/widget/app.py","related_paths":[],"rule":"DS-PY-COMPLEXITY","symbol":"choose"},{"end_line":7,"evidence":{"complexity":2,"limit":1},"id":"sha256:b45437a83e217256f4e035e6996d84bee727811654eb04aa532103c937063f5c","level":"warning","line":3,"message":"Function decision count exceeds policy","path":"src/widget/parser.py","related_paths":[],"rule":"DS-PY-COMPLEXITY","symbol":"fallback"},{"end_line":7,"evidence":{"catches":"BaseException"},"id":"sha256:b5b403f6505e4667b581bc3af4381608765b90c45c553da2e63cb5e7a13360b1","level":"warning","line":6,"message":"Bare except also catches process control exceptions","path":"src/widget/parser.py","related_paths":[],"rule":"DS-PY-BARE-EXCEPT","symbol":"fallback.#1"}],"history":{"end":"2026-10-08T12:00:00Z","reasons":[],"start":"2026-09-10T12:00:00Z","status":"COMPLETE"},"hotspots":[{"added":7,"binary_changes":0,"coverage":"COMPLETE","deleted":0,"path":"src/widget/parser.py","revisions":1},{"added":6,"binary_changes":0,"coverage":"COMPLETE","deleted":0,"path":"src/widget/app.py","revisions":1},{"added":1,"binary_changes":0,"coverage":"COMPLETE","deleted":0,"path":"README.md","revisions":1},{"added":0,"binary_changes":0,"coverage":"COMPLETE","deleted":0,"path":"src/widget/__init__.py","revisions":1}],"import_cycles":[["src/widget/app.py","src/widget/parser.py"]],"imports":[{"candidates":["src/widget/parser.py"],"from":"src/widget/app.py","line":1,"module":"widget.parser","status":"RESOLVED","to":"src/widget/parser.py"},{"candidates":["src/widget/app.py"],"from":"src/widget/parser.py","line":1,"module":"widget.app","status":"RESOLVED","to":"src/widget/app.py"}],"observed_at":"2026-10-08T12:00:00Z","subject":{"kind":"LOCAL_GIT","object_format":"sha1","repository_id":"sha256:82213dcc32cfbeb5300f0011e499ccfddfaa6bb8dd6006afd8a28bd700cf6ece","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","roots":["505ec5559236affd51ee6621b0eb54780bcd0e11"],"tree":"a11d47f209c720b4036bab407ed91550e55529d0"}} \ No newline at end of file diff --git a/examples/code/bundle/findings.sarif b/examples/code/bundle/findings.sarif new file mode 100644 index 0000000..df12830 --- /dev/null +++ b/examples/code/bundle/findings.sarif @@ -0,0 +1 @@ +{"$schema":"https://json.schemastore.org/sarif-2.1.0.json","runs":[{"properties":{"authority":"STATIC_SOURCE_EVIDENCE; no execution, independent acceptance, health score or merge authority","historyCoverage":"COMPLETE","sourceCoverage":"COMPLETE"},"results":[{"level":"warning","locations":[{"physicalLocation":{"artifactLocation":{"uri":"src/widget/app.py"},"region":{"endLine":1,"startLine":1}}}],"message":{"text":"Static import cycle"},"partialFingerprints":{"devostasisFindingId":"sha256:f360634dd4075ebb04d4c979fe5a321757b8489275c06350a86e6347d99f3fac"},"properties":{"evidence":{"paths":["src/widget/app.py","src/widget/parser.py"]},"relatedPaths":["src/widget/app.py","src/widget/parser.py"],"symbol":"cycle:d205d439f837a19cc3f0467afed21a795b586999d0c79f8d3c9fba70482306cc"},"ruleId":"DS-PY-IMPORT-CYCLE"},{"level":"warning","locations":[{"physicalLocation":{"artifactLocation":{"uri":"src/widget/app.py"},"region":{"endLine":6,"startLine":3}}}],"message":{"text":"Function decision count exceeds policy"},"partialFingerprints":{"devostasisFindingId":"sha256:e61175cd8aee0c21f82268ef4094ea2febe3cf8cbced21d8ea71213776003b36"},"properties":{"evidence":{"complexity":2,"limit":1},"relatedPaths":[],"symbol":"choose"},"ruleId":"DS-PY-COMPLEXITY"},{"level":"warning","locations":[{"physicalLocation":{"artifactLocation":{"uri":"src/widget/parser.py"},"region":{"endLine":7,"startLine":3}}}],"message":{"text":"Function decision count exceeds policy"},"partialFingerprints":{"devostasisFindingId":"sha256:b45437a83e217256f4e035e6996d84bee727811654eb04aa532103c937063f5c"},"properties":{"evidence":{"complexity":2,"limit":1},"relatedPaths":[],"symbol":"fallback"},"ruleId":"DS-PY-COMPLEXITY"},{"level":"warning","locations":[{"physicalLocation":{"artifactLocation":{"uri":"src/widget/parser.py"},"region":{"endLine":7,"startLine":6}}}],"message":{"text":"Bare except also catches process control exceptions"},"partialFingerprints":{"devostasisFindingId":"sha256:b5b403f6505e4667b581bc3af4381608765b90c45c553da2e63cb5e7a13360b1"},"properties":{"evidence":{"catches":"BaseException"},"relatedPaths":[],"symbol":"fallback.#1"},"ruleId":"DS-PY-BARE-EXCEPT"}],"tool":{"driver":{"name":"Devostasis Code Analysis","rules":[{"id":"DS-PY-BARE-EXCEPT","shortDescription":{"text":"Bare except also catches process control exceptions"}},{"id":"DS-PY-COMPLEXITY","shortDescription":{"text":"Function decision count exceeds policy"}},{"id":"DS-PY-FILE-SIZE","shortDescription":{"text":"File line count exceeds policy"}},{"id":"DS-PY-FUNCTION-SIZE","shortDescription":{"text":"Function line span exceeds policy"}},{"id":"DS-PY-IMPORT-CYCLE","shortDescription":{"text":"Static import cycle"}},{"id":"DS-PY-SYNTAX","shortDescription":{"text":"Python syntax cannot be parsed"}}],"version":"devostasis.code-engine.v1"}},"versionControlProvenance":[{"revisionId":"505ec5559236affd51ee6621b0eb54780bcd0e11"}]}],"version":"2.1.0"} \ No newline at end of file diff --git a/examples/code/bundle/inputs.json b/examples/code/bundle/inputs.json new file mode 100644 index 0000000..eeca381 --- /dev/null +++ b/examples/code/bundle/inputs.json @@ -0,0 +1 @@ +{"entries":[{"kind":"blob","mode":"100644","object_id":"243c57287062ba5fb0d050828cdbcc0b91b39aa0","path":"README.md","path_base64":"UkVBRE1FLm1k","reason":"LANGUAGE_UNSUPPORTED","size":26,"status":"UNSUPPORTED"},{"kind":"blob","mode":"100644","object_id":"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391","path":"src/widget/__init__.py","path_base64":"c3JjL3dpZGdldC9fX2luaXRfXy5weQ==","reason":null,"size":0,"status":"ADMITTED"},{"kind":"blob","mode":"100644","object_id":"619dd2aa0d86bec82bdc88f2bbfc166e233feeb7","path":"src/widget/app.py","path_base64":"c3JjL3dpZGdldC9hcHAucHk=","reason":null,"size":85,"status":"ADMITTED"},{"kind":"blob","mode":"100644","object_id":"e18e086cd787854f50a4270afa266031f783529f","path":"src/widget/parser.py","path_base64":"c3JjL3dpZGdldC9wYXJzZXIucHk=","reason":null,"size":90,"status":"ADMITTED"}],"history":{"commits":[{"changes":[{"added":1,"deleted":0,"path":"README.md"},{"added":0,"deleted":0,"path":"src/widget/__init__.py"},{"added":6,"deleted":0,"path":"src/widget/app.py"},{"added":7,"deleted":0,"path":"src/widget/parser.py"}],"committed_at":"2026-10-08T12:00:00Z","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11"}],"end":"2026-10-08T12:00:00Z","reasons":[],"start":"2026-09-10T12:00:00Z","status":"COMPLETE"},"observed_at":"2026-10-08T12:00:00Z","proof":{"commit":"dHJlZSBhMTFkNDdmMjA5YzcyMGI0MDM2YmFiNDA3ZWQ5MTU1MGU1NTUyOWQwCmF1dGhvciBTeW50aGV0aWMgRml4dHVyZSA8Zml4dHVyZUBleGFtcGxlLmludmFsaWQ+IDE3OTE0NjA4MDAgKzAwMDAKY29tbWl0dGVyIFN5bnRoZXRpYyBGaXh0dXJlIDxmaXh0dXJlQGV4YW1wbGUuaW52YWxpZD4gMTc5MTQ2MDgwMCArMDAwMAoKU3ludGhldGljIG9mZmxpbmUgYW5hbHlzaXMgZml4dHVyZQo=","trees":{"1c953551c814aa05de7174253135818b33d38f70":"MTAwNjQ0IF9faW5pdF9fLnB5AOad4puy0dZDS4sprnda2MLkjFORMTAwNjQ0IGFwcC5weQBhndKqDYa+yCvciPK7/BZuIz/utzEwMDY0NCBwYXJzZXIucHkA4Y4IbNeHhU9QpCcK+iZgMfeDUp8=","a11d47f209c720b4036bab407ed91550e55529d0":"MTAwNjQ0IFJFQURNRS5tZAAkPFcocGK6X7DQUIKM28wLkbOaoDQwMDAwIHNyYwDT7WOOuuPU6/KOSC3gDnA2Ttck2w==","d3ed638ebae3d4ebf28e482de00e70364ed724db":"NDAwMDAgd2lkZ2V0AByVNVHIFKoF3nF0JTE1gYsz049w"}},"sources":{"src/widget/__init__.py":"","src/widget/app.py":"ZnJvbSAuIGltcG9ydCBwYXJzZXIKCmRlZiBjaG9vc2UodmFsdWUpOgogICAgaWYgdmFsdWU6CiAgICAgICAgcmV0dXJuIDEKICAgIHJldHVybiAwCg==","src/widget/parser.py":"ZnJvbSAuIGltcG9ydCBhcHAKCmRlZiBmYWxsYmFjaygpOgogICAgdHJ5OgogICAgICAgIHJldHVybiAxCiAgICBleGNlcHQ6CiAgICAgICAgcmV0dXJuIDAK"},"subject":{"kind":"LOCAL_GIT","object_format":"sha1","repository_id":"sha256:82213dcc32cfbeb5300f0011e499ccfddfaa6bb8dd6006afd8a28bd700cf6ece","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","roots":["505ec5559236affd51ee6621b0eb54780bcd0e11"],"tree":"a11d47f209c720b4036bab407ed91550e55529d0"}} \ No newline at end of file diff --git a/examples/code/bundle/manifest.json b/examples/code/bundle/manifest.json new file mode 100644 index 0000000..889a971 --- /dev/null +++ b/examples/code/bundle/manifest.json @@ -0,0 +1 @@ +{"bundle_id":"sha256:c7852cfdf30a631571f802cd21305e60d215ac69f0965e6008c671a922e8f9e9","contract":"devostasis.code-analysis.v1","engine":"devostasis.code-engine.v1","members":{"analysis.json":"sha256:325f493dd7752af3966099f459cecc9d70dd75c9920778c9d8198a4bb08ff176","findings.sarif":"sha256:8ebceb314d4dfc405212a596c8710a59dd62d08be813ef28a19406ae5ca201af","inputs.json":"sha256:39f0834b21b37bd8568691adcf9ac628c0f00868eda1ef3418a1df13e3365ad5","policy.json":"sha256:d425c52359faee7f7dc868ddfe4d976165ee1225baea66d86f4d22b0d51832fa","report.md":"sha256:99a2cc68889be28d42bfdfa0eec173c6d69d7ffdf9c96c888cd665e0daa89da7"},"subject":{"kind":"LOCAL_GIT","object_format":"sha1","repository_id":"sha256:82213dcc32cfbeb5300f0011e499ccfddfaa6bb8dd6006afd8a28bd700cf6ece","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","roots":["505ec5559236affd51ee6621b0eb54780bcd0e11"],"tree":"a11d47f209c720b4036bab407ed91550e55529d0"}} \ No newline at end of file diff --git a/examples/code/bundle/policy.json b/examples/code/bundle/policy.json new file mode 100644 index 0000000..4bb395e --- /dev/null +++ b/examples/code/bundle/policy.json @@ -0,0 +1 @@ +{"budget":{"max_file_bytes":1048576,"max_files":2000,"max_total_bytes":16777216,"seconds":120},"contract":"devostasis.code-policy.v1","exclude":[".venv","build","dist","vendor","node_modules"],"grammar":"PYTHON_3_12","history":{"days":28,"enabled":true,"max_commits":500},"include":[],"limits":{"complexity":1,"file_lines":600,"function_lines":80},"source_roots":[".","src"],"version":"default-1"} \ No newline at end of file diff --git a/examples/code/bundle/report.md b/examples/code/bundle/report.md new file mode 100644 index 0000000..bd1218a --- /dev/null +++ b/examples/code/bundle/report.md @@ -0,0 +1,52 @@ +# Offline repository analysis + +Revision: `505ec5559236affd51ee6621b0eb54780bcd0e11` + +Python grammar: 3.12. Static evidence only; repository code was not executed. + +Python source coverage: COMPLETE; history: COMPLETE. + +Unsupported and unavailable inputs do not establish an absence of findings. + +| Source status | Files | +| --- | ---: | +| ANALYZED | 3 | +| PARSE_ERROR | 0 | +| UNAVAILABLE | 0 | +| UNSUPPORTED | 1 | +| OUT_OF_SCOPE | 0 | + +## Findings + +| Path | Line | Rule | Symbol | Evidence | +| --- | ---: | --- | --- | --- | +| src/widget/app\.py | 1 | DS-PY-IMPORT-CYCLE | cycle:d205d439f837a19cc3f0467afed21a795b586999d0c79f8d3c9fba70482306cc | \{"paths":\["src/widget/app\.py","src/widget/parser\.py"\]\} | +| src/widget/app\.py | 3 | DS-PY-COMPLEXITY | choose | \{"complexity":2,"limit":1\} | +| src/widget/parser\.py | 3 | DS-PY-COMPLEXITY | fallback | \{"complexity":2,"limit":1\} | +| src/widget/parser\.py | 6 | DS-PY-BARE-EXCEPT | fallback\.<bare-except>\#1 | \{"catches":"BaseException"\} | + +Showing 4 of 4 findings; analysis.json and findings.sarif retain every finding. + +## Source gaps + +| Path | Status | Reason | +| --- | --- | --- | + +Showing 0 of 0 source gaps. + +## Recent change evidence + +Non-merge commits in [2026-09-10T12:00:00Z, 2026-10-08T12:00:00Z]. + +Counts under PARTIAL history are observed counts, not complete totals or productivity. + +| Path | Revisions | Added | Deleted | Coverage | +| --- | ---: | ---: | ---: | --- | +| src/widget/parser\.py | 1 | 7 | 0 | COMPLETE | +| src/widget/app\.py | 1 | 6 | 0 | COMPLETE | +| README\.md | 1 | 1 | 0 | COMPLETE | +| src/widget/\_\_init\_\_\.py | 1 | 0 | 0 | COMPLETE | + +Showing 4 of 4 paths. + +STATIC_SOURCE_EVIDENCE; no execution, independent acceptance, health score or merge authority diff --git a/examples/code/evidence.json b/examples/code/evidence.json new file mode 100644 index 0000000..093c52e --- /dev/null +++ b/examples/code/evidence.json @@ -0,0 +1 @@ +{"contract":"devostasis.work.v2","kind":"evidence","sources":[{"observed_at":"2026-10-08T12:00:00Z","producer":"devostasis.code-engine.v1","profile":"sarif.v1","reason":null,"report":{"base64":"eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLnNjaGVtYXN0b3JlLm9yZy9zYXJpZi0yLjEuMC5qc29uIiwicnVucyI6W3sicHJvcGVydGllcyI6eyJhdXRob3JpdHkiOiJTVEFUSUNfU09VUkNFX0VWSURFTkNFOyBubyBleGVjdXRpb24sIGluZGVwZW5kZW50IGFjY2VwdGFuY2UsIGhlYWx0aCBzY29yZSBvciBtZXJnZSBhdXRob3JpdHkiLCJoaXN0b3J5Q292ZXJhZ2UiOiJDT01QTEVURSIsInNvdXJjZUNvdmVyYWdlIjoiQ09NUExFVEUifSwicmVzdWx0cyI6W3sibGV2ZWwiOiJ3YXJuaW5nIiwibG9jYXRpb25zIjpbeyJwaHlzaWNhbExvY2F0aW9uIjp7ImFydGlmYWN0TG9jYXRpb24iOnsidXJpIjoic3JjL3dpZGdldC9hcHAucHkifSwicmVnaW9uIjp7ImVuZExpbmUiOjEsInN0YXJ0TGluZSI6MX19fV0sIm1lc3NhZ2UiOnsidGV4dCI6IlN0YXRpYyBpbXBvcnQgY3ljbGUifSwicGFydGlhbEZpbmdlcnByaW50cyI6eyJkZXZvc3Rhc2lzRmluZGluZ0lkIjoic2hhMjU2OmYzNjA2MzRkZDQwNzVlYmIwNGQ0Yzk3OWZlNWEzMjE3NTdiODQ4OTI3NWMwNjM1MGE4NmU2MzQ3ZDk5ZjNmYWMifSwicHJvcGVydGllcyI6eyJldmlkZW5jZSI6eyJwYXRocyI6WyJzcmMvd2lkZ2V0L2FwcC5weSIsInNyYy93aWRnZXQvcGFyc2VyLnB5Il19LCJyZWxhdGVkUGF0aHMiOlsic3JjL3dpZGdldC9hcHAucHkiLCJzcmMvd2lkZ2V0L3BhcnNlci5weSJdLCJzeW1ib2wiOiJjeWNsZTpkMjA1ZDQzOWY4MzdhMTljYzNmMDQ2N2FmZWQyMWE3OTViNTg2OTk5ZDBjNzlmOGQzYzlmYmE3MDQ4MjMwNmNjIn0sInJ1bGVJZCI6IkRTLVBZLUlNUE9SVC1DWUNMRSJ9LHsibGV2ZWwiOiJ3YXJuaW5nIiwibG9jYXRpb25zIjpbeyJwaHlzaWNhbExvY2F0aW9uIjp7ImFydGlmYWN0TG9jYXRpb24iOnsidXJpIjoic3JjL3dpZGdldC9hcHAucHkifSwicmVnaW9uIjp7ImVuZExpbmUiOjYsInN0YXJ0TGluZSI6M319fV0sIm1lc3NhZ2UiOnsidGV4dCI6IkZ1bmN0aW9uIGRlY2lzaW9uIGNvdW50IGV4Y2VlZHMgcG9saWN5In0sInBhcnRpYWxGaW5nZXJwcmludHMiOnsiZGV2b3N0YXNpc0ZpbmRpbmdJZCI6InNoYTI1NjplNjExNzVjZDhhZWUwYzIxZjgyMjY4ZWY0MDk0ZWEyZmViZTNjZjhjYmNlZDIxZDhlYTcxMjEzNzc2MDAzYjM2In0sInByb3BlcnRpZXMiOnsiZXZpZGVuY2UiOnsiY29tcGxleGl0eSI6MiwibGltaXQiOjF9LCJyZWxhdGVkUGF0aHMiOltdLCJzeW1ib2wiOiJjaG9vc2UifSwicnVsZUlkIjoiRFMtUFktQ09NUExFWElUWSJ9LHsibGV2ZWwiOiJ3YXJuaW5nIiwibG9jYXRpb25zIjpbeyJwaHlzaWNhbExvY2F0aW9uIjp7ImFydGlmYWN0TG9jYXRpb24iOnsidXJpIjoic3JjL3dpZGdldC9wYXJzZXIucHkifSwicmVnaW9uIjp7ImVuZExpbmUiOjcsInN0YXJ0TGluZSI6M319fV0sIm1lc3NhZ2UiOnsidGV4dCI6IkZ1bmN0aW9uIGRlY2lzaW9uIGNvdW50IGV4Y2VlZHMgcG9saWN5In0sInBhcnRpYWxGaW5nZXJwcmludHMiOnsiZGV2b3N0YXNpc0ZpbmRpbmdJZCI6InNoYTI1NjpiNDU0MzdhODNlMjE3MjU2ZjRlMDM1ZTY5OTZkODRiZWU3Mjc4MTE2NTRlYjA0YWE1MzIxMDNjOTM3MDYzZjVjIn0sInByb3BlcnRpZXMiOnsiZXZpZGVuY2UiOnsiY29tcGxleGl0eSI6MiwibGltaXQiOjF9LCJyZWxhdGVkUGF0aHMiOltdLCJzeW1ib2wiOiJmYWxsYmFjayJ9LCJydWxlSWQiOiJEUy1QWS1DT01QTEVYSVRZIn0seyJsZXZlbCI6Indhcm5pbmciLCJsb2NhdGlvbnMiOlt7InBoeXNpY2FsTG9jYXRpb24iOnsiYXJ0aWZhY3RMb2NhdGlvbiI6eyJ1cmkiOiJzcmMvd2lkZ2V0L3BhcnNlci5weSJ9LCJyZWdpb24iOnsiZW5kTGluZSI6Nywic3RhcnRMaW5lIjo2fX19XSwibWVzc2FnZSI6eyJ0ZXh0IjoiQmFyZSBleGNlcHQgYWxzbyBjYXRjaGVzIHByb2Nlc3MgY29udHJvbCBleGNlcHRpb25zIn0sInBhcnRpYWxGaW5nZXJwcmludHMiOnsiZGV2b3N0YXNpc0ZpbmRpbmdJZCI6InNoYTI1NjpiNWI0MDNmNjUwNWU0NjY3YjU4MWJjM2FmNDM4MTYwODc2NWI5MGM0NWM1NTNkYTJlNjNjYjVlN2ExMzM2MGIxIn0sInByb3BlcnRpZXMiOnsiZXZpZGVuY2UiOnsiY2F0Y2hlcyI6IkJhc2VFeGNlcHRpb24ifSwicmVsYXRlZFBhdGhzIjpbXSwic3ltYm9sIjoiZmFsbGJhY2suPGJhcmUtZXhjZXB0PiMxIn0sInJ1bGVJZCI6IkRTLVBZLUJBUkUtRVhDRVBUIn1dLCJ0b29sIjp7ImRyaXZlciI6eyJuYW1lIjoiRGV2b3N0YXNpcyBDb2RlIEFuYWx5c2lzIiwicnVsZXMiOlt7ImlkIjoiRFMtUFktQkFSRS1FWENFUFQiLCJzaG9ydERlc2NyaXB0aW9uIjp7InRleHQiOiJCYXJlIGV4Y2VwdCBhbHNvIGNhdGNoZXMgcHJvY2VzcyBjb250cm9sIGV4Y2VwdGlvbnMifX0seyJpZCI6IkRTLVBZLUNPTVBMRVhJVFkiLCJzaG9ydERlc2NyaXB0aW9uIjp7InRleHQiOiJGdW5jdGlvbiBkZWNpc2lvbiBjb3VudCBleGNlZWRzIHBvbGljeSJ9fSx7ImlkIjoiRFMtUFktRklMRS1TSVpFIiwic2hvcnREZXNjcmlwdGlvbiI6eyJ0ZXh0IjoiRmlsZSBsaW5lIGNvdW50IGV4Y2VlZHMgcG9saWN5In19LHsiaWQiOiJEUy1QWS1GVU5DVElPTi1TSVpFIiwic2hvcnREZXNjcmlwdGlvbiI6eyJ0ZXh0IjoiRnVuY3Rpb24gbGluZSBzcGFuIGV4Y2VlZHMgcG9saWN5In19LHsiaWQiOiJEUy1QWS1JTVBPUlQtQ1lDTEUiLCJzaG9ydERlc2NyaXB0aW9uIjp7InRleHQiOiJTdGF0aWMgaW1wb3J0IGN5Y2xlIn19LHsiaWQiOiJEUy1QWS1TWU5UQVgiLCJzaG9ydERlc2NyaXB0aW9uIjp7InRleHQiOiJQeXRob24gc3ludGF4IGNhbm5vdCBiZSBwYXJzZWQifX1dLCJ2ZXJzaW9uIjoiZGV2b3N0YXNpcy5jb2RlLWVuZ2luZS52MSJ9fSwidmVyc2lvbkNvbnRyb2xQcm92ZW5hbmNlIjpbeyJyZXZpc2lvbklkIjoiNTA1ZWM1NTU5MjM2YWZmZDUxZWU2NjIxYjBlYjU0NzgwYmNkMGUxMSJ9XX1dLCJ2ZXJzaW9uIjoiMi4xLjAifQ==","digest":"sha256:8ebceb314d4dfc405212a596c8710a59dd62d08be813ef28a19406ae5ca201af"},"status":"COMPLETE","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}},"version":"1"}]} \ No newline at end of file diff --git a/examples/code/inventory.json b/examples/code/inventory.json new file mode 100644 index 0000000..fb5e3ac --- /dev/null +++ b/examples/code/inventory.json @@ -0,0 +1 @@ +{"changes":{"items":[],"reasons":[],"status":"COMPLETE"},"contract":"devostasis.work.v2","issues":{"items":[],"reasons":[],"status":"COMPLETE"},"kind":"inventory","observed_at":"2026-10-08T12:00:00Z","receipts":[],"selection":{"changes":"OPEN_AND_REGISTERED","issues":[]},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}}} \ No newline at end of file diff --git a/examples/code/packet.json b/examples/code/packet.json new file mode 100644 index 0000000..2788080 --- /dev/null +++ b/examples/code/packet.json @@ -0,0 +1 @@ +{"analysis_bundle_id":"sha256:c7852cfdf30a631571f802cd21305e60d215ac69f0965e6008c671a922e8f9e9","authority":"PINNED_LOCAL_SOURCE_ONLY; no live freshness, independent acceptance or execution authority","budget":{"max_bytes":1048576,"max_files":20},"contract":"devostasis.code-packet.v1","files":[{"base64":"ZnJvbSAuIGltcG9ydCBwYXJzZXIKCmRlZiBjaG9vc2UodmFsdWUpOgogICAgaWYgdmFsdWU6CiAgICAgICAgcmV0dXJuIDEKICAgIHJldHVybiAwCg==","object_id":"619dd2aa0d86bec82bdc88f2bbfc166e233feeb7","path":"src/widget/app.py","size":85}],"packet_id":"sha256:7997375eb124d18feb1dd2ff1f3db6cd8cee7898b6547b0ffe121635c9cb4362","subject":{"kind":"LOCAL_GIT","object_format":"sha1","repository_id":"sha256:82213dcc32cfbeb5300f0011e499ccfddfaa6bb8dd6006afd8a28bd700cf6ece","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","roots":["505ec5559236affd51ee6621b0eb54780bcd0e11"],"tree":"a11d47f209c720b4036bab407ed91550e55529d0"},"total_bytes":85} \ No newline at end of file diff --git a/examples/code/policy.json b/examples/code/policy.json new file mode 100644 index 0000000..4bb395e --- /dev/null +++ b/examples/code/policy.json @@ -0,0 +1 @@ +{"budget":{"max_file_bytes":1048576,"max_files":2000,"max_total_bytes":16777216,"seconds":120},"contract":"devostasis.code-policy.v1","exclude":[".venv","build","dist","vendor","node_modules"],"grammar":"PYTHON_3_12","history":{"days":28,"enabled":true,"max_commits":500},"include":[],"limits":{"complexity":1,"file_lines":600,"function_lines":80},"source_roots":[".","src"],"version":"default-1"} \ No newline at end of file diff --git a/examples/code/work-bundle/evidence.json b/examples/code/work-bundle/evidence.json new file mode 100644 index 0000000..093c52e --- /dev/null +++ b/examples/code/work-bundle/evidence.json @@ -0,0 +1 @@ +{"contract":"devostasis.work.v2","kind":"evidence","sources":[{"observed_at":"2026-10-08T12:00:00Z","producer":"devostasis.code-engine.v1","profile":"sarif.v1","reason":null,"report":{"base64":"eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLnNjaGVtYXN0b3JlLm9yZy9zYXJpZi0yLjEuMC5qc29uIiwicnVucyI6W3sicHJvcGVydGllcyI6eyJhdXRob3JpdHkiOiJTVEFUSUNfU09VUkNFX0VWSURFTkNFOyBubyBleGVjdXRpb24sIGluZGVwZW5kZW50IGFjY2VwdGFuY2UsIGhlYWx0aCBzY29yZSBvciBtZXJnZSBhdXRob3JpdHkiLCJoaXN0b3J5Q292ZXJhZ2UiOiJDT01QTEVURSIsInNvdXJjZUNvdmVyYWdlIjoiQ09NUExFVEUifSwicmVzdWx0cyI6W3sibGV2ZWwiOiJ3YXJuaW5nIiwibG9jYXRpb25zIjpbeyJwaHlzaWNhbExvY2F0aW9uIjp7ImFydGlmYWN0TG9jYXRpb24iOnsidXJpIjoic3JjL3dpZGdldC9hcHAucHkifSwicmVnaW9uIjp7ImVuZExpbmUiOjEsInN0YXJ0TGluZSI6MX19fV0sIm1lc3NhZ2UiOnsidGV4dCI6IlN0YXRpYyBpbXBvcnQgY3ljbGUifSwicGFydGlhbEZpbmdlcnByaW50cyI6eyJkZXZvc3Rhc2lzRmluZGluZ0lkIjoic2hhMjU2OmYzNjA2MzRkZDQwNzVlYmIwNGQ0Yzk3OWZlNWEzMjE3NTdiODQ4OTI3NWMwNjM1MGE4NmU2MzQ3ZDk5ZjNmYWMifSwicHJvcGVydGllcyI6eyJldmlkZW5jZSI6eyJwYXRocyI6WyJzcmMvd2lkZ2V0L2FwcC5weSIsInNyYy93aWRnZXQvcGFyc2VyLnB5Il19LCJyZWxhdGVkUGF0aHMiOlsic3JjL3dpZGdldC9hcHAucHkiLCJzcmMvd2lkZ2V0L3BhcnNlci5weSJdLCJzeW1ib2wiOiJjeWNsZTpkMjA1ZDQzOWY4MzdhMTljYzNmMDQ2N2FmZWQyMWE3OTViNTg2OTk5ZDBjNzlmOGQzYzlmYmE3MDQ4MjMwNmNjIn0sInJ1bGVJZCI6IkRTLVBZLUlNUE9SVC1DWUNMRSJ9LHsibGV2ZWwiOiJ3YXJuaW5nIiwibG9jYXRpb25zIjpbeyJwaHlzaWNhbExvY2F0aW9uIjp7ImFydGlmYWN0TG9jYXRpb24iOnsidXJpIjoic3JjL3dpZGdldC9hcHAucHkifSwicmVnaW9uIjp7ImVuZExpbmUiOjYsInN0YXJ0TGluZSI6M319fV0sIm1lc3NhZ2UiOnsidGV4dCI6IkZ1bmN0aW9uIGRlY2lzaW9uIGNvdW50IGV4Y2VlZHMgcG9saWN5In0sInBhcnRpYWxGaW5nZXJwcmludHMiOnsiZGV2b3N0YXNpc0ZpbmRpbmdJZCI6InNoYTI1NjplNjExNzVjZDhhZWUwYzIxZjgyMjY4ZWY0MDk0ZWEyZmViZTNjZjhjYmNlZDIxZDhlYTcxMjEzNzc2MDAzYjM2In0sInByb3BlcnRpZXMiOnsiZXZpZGVuY2UiOnsiY29tcGxleGl0eSI6MiwibGltaXQiOjF9LCJyZWxhdGVkUGF0aHMiOltdLCJzeW1ib2wiOiJjaG9vc2UifSwicnVsZUlkIjoiRFMtUFktQ09NUExFWElUWSJ9LHsibGV2ZWwiOiJ3YXJuaW5nIiwibG9jYXRpb25zIjpbeyJwaHlzaWNhbExvY2F0aW9uIjp7ImFydGlmYWN0TG9jYXRpb24iOnsidXJpIjoic3JjL3dpZGdldC9wYXJzZXIucHkifSwicmVnaW9uIjp7ImVuZExpbmUiOjcsInN0YXJ0TGluZSI6M319fV0sIm1lc3NhZ2UiOnsidGV4dCI6IkZ1bmN0aW9uIGRlY2lzaW9uIGNvdW50IGV4Y2VlZHMgcG9saWN5In0sInBhcnRpYWxGaW5nZXJwcmludHMiOnsiZGV2b3N0YXNpc0ZpbmRpbmdJZCI6InNoYTI1NjpiNDU0MzdhODNlMjE3MjU2ZjRlMDM1ZTY5OTZkODRiZWU3Mjc4MTE2NTRlYjA0YWE1MzIxMDNjOTM3MDYzZjVjIn0sInByb3BlcnRpZXMiOnsiZXZpZGVuY2UiOnsiY29tcGxleGl0eSI6MiwibGltaXQiOjF9LCJyZWxhdGVkUGF0aHMiOltdLCJzeW1ib2wiOiJmYWxsYmFjayJ9LCJydWxlSWQiOiJEUy1QWS1DT01QTEVYSVRZIn0seyJsZXZlbCI6Indhcm5pbmciLCJsb2NhdGlvbnMiOlt7InBoeXNpY2FsTG9jYXRpb24iOnsiYXJ0aWZhY3RMb2NhdGlvbiI6eyJ1cmkiOiJzcmMvd2lkZ2V0L3BhcnNlci5weSJ9LCJyZWdpb24iOnsiZW5kTGluZSI6Nywic3RhcnRMaW5lIjo2fX19XSwibWVzc2FnZSI6eyJ0ZXh0IjoiQmFyZSBleGNlcHQgYWxzbyBjYXRjaGVzIHByb2Nlc3MgY29udHJvbCBleGNlcHRpb25zIn0sInBhcnRpYWxGaW5nZXJwcmludHMiOnsiZGV2b3N0YXNpc0ZpbmRpbmdJZCI6InNoYTI1NjpiNWI0MDNmNjUwNWU0NjY3YjU4MWJjM2FmNDM4MTYwODc2NWI5MGM0NWM1NTNkYTJlNjNjYjVlN2ExMzM2MGIxIn0sInByb3BlcnRpZXMiOnsiZXZpZGVuY2UiOnsiY2F0Y2hlcyI6IkJhc2VFeGNlcHRpb24ifSwicmVsYXRlZFBhdGhzIjpbXSwic3ltYm9sIjoiZmFsbGJhY2suPGJhcmUtZXhjZXB0PiMxIn0sInJ1bGVJZCI6IkRTLVBZLUJBUkUtRVhDRVBUIn1dLCJ0b29sIjp7ImRyaXZlciI6eyJuYW1lIjoiRGV2b3N0YXNpcyBDb2RlIEFuYWx5c2lzIiwicnVsZXMiOlt7ImlkIjoiRFMtUFktQkFSRS1FWENFUFQiLCJzaG9ydERlc2NyaXB0aW9uIjp7InRleHQiOiJCYXJlIGV4Y2VwdCBhbHNvIGNhdGNoZXMgcHJvY2VzcyBjb250cm9sIGV4Y2VwdGlvbnMifX0seyJpZCI6IkRTLVBZLUNPTVBMRVhJVFkiLCJzaG9ydERlc2NyaXB0aW9uIjp7InRleHQiOiJGdW5jdGlvbiBkZWNpc2lvbiBjb3VudCBleGNlZWRzIHBvbGljeSJ9fSx7ImlkIjoiRFMtUFktRklMRS1TSVpFIiwic2hvcnREZXNjcmlwdGlvbiI6eyJ0ZXh0IjoiRmlsZSBsaW5lIGNvdW50IGV4Y2VlZHMgcG9saWN5In19LHsiaWQiOiJEUy1QWS1GVU5DVElPTi1TSVpFIiwic2hvcnREZXNjcmlwdGlvbiI6eyJ0ZXh0IjoiRnVuY3Rpb24gbGluZSBzcGFuIGV4Y2VlZHMgcG9saWN5In19LHsiaWQiOiJEUy1QWS1JTVBPUlQtQ1lDTEUiLCJzaG9ydERlc2NyaXB0aW9uIjp7InRleHQiOiJTdGF0aWMgaW1wb3J0IGN5Y2xlIn19LHsiaWQiOiJEUy1QWS1TWU5UQVgiLCJzaG9ydERlc2NyaXB0aW9uIjp7InRleHQiOiJQeXRob24gc3ludGF4IGNhbm5vdCBiZSBwYXJzZWQifX1dLCJ2ZXJzaW9uIjoiZGV2b3N0YXNpcy5jb2RlLWVuZ2luZS52MSJ9fSwidmVyc2lvbkNvbnRyb2xQcm92ZW5hbmNlIjpbeyJyZXZpc2lvbklkIjoiNTA1ZWM1NTU5MjM2YWZmZDUxZWU2NjIxYjBlYjU0NzgwYmNkMGUxMSJ9XX1dLCJ2ZXJzaW9uIjoiMi4xLjAifQ==","digest":"sha256:8ebceb314d4dfc405212a596c8710a59dd62d08be813ef28a19406ae5ca201af"},"status":"COMPLETE","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}},"version":"1"}]} \ No newline at end of file diff --git a/examples/code/work-bundle/inventory.json b/examples/code/work-bundle/inventory.json new file mode 100644 index 0000000..fb5e3ac --- /dev/null +++ b/examples/code/work-bundle/inventory.json @@ -0,0 +1 @@ +{"changes":{"items":[],"reasons":[],"status":"COMPLETE"},"contract":"devostasis.work.v2","issues":{"items":[],"reasons":[],"status":"COMPLETE"},"kind":"inventory","observed_at":"2026-10-08T12:00:00Z","receipts":[],"selection":{"changes":"OPEN_AND_REGISTERED","issues":[]},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}}} \ No newline at end of file diff --git a/examples/code/work-bundle/manifest.json b/examples/code/work-bundle/manifest.json new file mode 100644 index 0000000..59fe120 --- /dev/null +++ b/examples/code/work-bundle/manifest.json @@ -0,0 +1 @@ +{"bundle_id":"sha256:c3491fd8c9afd941b958893012e8debf64bbb5051a943b14ee26dda8ee8bb563","contract":"devostasis.work.v2","engine":"devostasis.work-engine.v2","kind":"bundle","members":{"evidence.json":"sha256:6ef5ce93fdb3c5a8d50b3c7ec29d7866e4074610027af86044b244bce594afe7","inventory.json":"sha256:5090e7b9655fd3d9939f8ba7c919a2659e045d75ce63ea40856ec073dfcce347","policy.json":"sha256:05cc5cc2caca768462c0e31b9da37aad7932663e12a10fee8ef127f3b2a1d683","report.md":"sha256:53c30af45d7a07ba3d44e12dadff20403eb80d4ce053b337026fe38bba33af01","scope.json":"sha256:7af7319c10080285b9e7fe6cd67539d12a5ece30257230896d6074b914d0c11f"},"vitals_anchor":null} \ No newline at end of file diff --git a/examples/code/work-bundle/policy.json b/examples/code/work-bundle/policy.json new file mode 100644 index 0000000..8b56348 --- /dev/null +++ b/examples/code/work-bundle/policy.json @@ -0,0 +1 @@ +{"actor":"worker","allow_merge_train":false,"contract":"devostasis.work.v2","criteria":[],"dispositions":[],"kind":"policy","minimum_approvals":1,"priorities":{},"queue_order":["review","finish_merge","implement_issue","research","analyze_code"],"read_budget":{"max_bytes":200000,"max_files":20},"requests":[],"required_checks":[],"roles":["all"],"ttl_seconds":3600,"version":"example-1"} \ No newline at end of file diff --git a/examples/code/work-bundle/report.md b/examples/code/work-bundle/report.md new file mode 100644 index 0000000..1fe2ede --- /dev/null +++ b/examples/code/work-bundle/report.md @@ -0,0 +1,15 @@ +# Devostasis work scope + +Observed: 2026-10-08T12:00:00Z · expires: 2026-10-08T13:00:00Z + +Read-only proposal. Recheck source state and consumer authorization before execution. + +| Queue | Eligibility | Source | Action | Reasons | +| --- | --- | --- | --- | --- | +| analyze_code | READY | sha256:7a4d46fe96cbd7f2b864fe0d69f7ff9a0484c8369076ca824082e8ae78b9b70e | analyze_code | | +| analyze_code | READY | sha256:818ea7a2a804bfb65dbc824407d2c110ddc22fd91f8236f50242e6a874d86f4a | analyze_code | | +| analyze_code | READY | sha256:6f0dd6f4cdb305e29ef81d517d9e3cc0054bb9840ca58acf00905f944c315413 | analyze_code | | +| analyze_code | READY | sha256:0325428e585c86655dc2b39c618fcdf0f83304edd455cb7e738b4c44f60178e4 | analyze_code | | + +Coverage: changes=COMPLETE, issues=COMPLETE +Excluded: 0; evidence gaps: 0. diff --git a/examples/code/work-bundle/scope.json b/examples/code/work-bundle/scope.json new file mode 100644 index 0000000..c59e07a --- /dev/null +++ b/examples/code/work-bundle/scope.json @@ -0,0 +1 @@ +{"authority":"Read-only proposal. Recheck source state and consumer authorization before execution.","contract":"devostasis.work.v2","coverage":{"changes":"COMPLETE","issues":"COMPLETE"},"excluded":[],"expires_at":"2026-10-08T13:00:00Z","gaps":[],"inputs":{"evidence":"sha256:6ef5ce93fdb3c5a8d50b3c7ec29d7866e4074610027af86044b244bce594afe7","inventory":"sha256:5090e7b9655fd3d9939f8ba7c919a2659e045d75ce63ea40856ec073dfcce347","policy":"sha256:05cc5cc2caca768462c0e31b9da37aad7932663e12a10fee8ef127f3b2a1d683"},"items":[{"acceptance":["Inspect the rule and location; reproduce or disposition the finding, then rerun the producer."],"action":"analyze_code","continuation":false,"criterion":"DS-PY-BARE-EXCEPT","dependencies":[],"eligibility":"READY","evidence":["sha256:05cc5cc2caca768462c0e31b9da37aad7932663e12a10fee8ef127f3b2a1d683","sha256:5090e7b9655fd3d9939f8ba7c919a2659e045d75ce63ea40856ec073dfcce347","sha256:6ef5ce93fdb3c5a8d50b3c7ec29d7866e4074610027af86044b244bce594afe7","sha256:8ebceb314d4dfc405212a596c8710a59dd62d08be813ef28a19406ae5ca201af"],"executor":"worker","fingerprint":"sha256:652deb8b461a11136ae4918110c26658c2b2eddd171deda87b97f72b89189268","id":"sha256:18204f3654202a90fbc686d9bcff6e836a1bdd6d723604d2df39be001415d28e","implementation_owner":null,"merge_gate":null,"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"analyze_code","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/widget/parser.py"],"symbols":[],"total_paths":1},"reasons":[],"revision":{"content":"505ec5559236affd51ee6621b0eb54780bcd0e11"},"source":"sha256:7a4d46fe96cbd7f2b864fe0d69f7ff9a0484c8369076ca824082e8ae78b9b70e","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}},"title":"Static analysis reported this rule at this location."},{"acceptance":["Inspect the rule and location; reproduce or disposition the finding, then rerun the producer."],"action":"analyze_code","continuation":false,"criterion":"DS-PY-COMPLEXITY","dependencies":[],"eligibility":"READY","evidence":["sha256:05cc5cc2caca768462c0e31b9da37aad7932663e12a10fee8ef127f3b2a1d683","sha256:5090e7b9655fd3d9939f8ba7c919a2659e045d75ce63ea40856ec073dfcce347","sha256:6ef5ce93fdb3c5a8d50b3c7ec29d7866e4074610027af86044b244bce594afe7","sha256:8ebceb314d4dfc405212a596c8710a59dd62d08be813ef28a19406ae5ca201af"],"executor":"worker","fingerprint":"sha256:9e309a0dc73ab7fb8f1611ec3b6e9609ddf7f1db54ae24c73872db27bf73fd68","id":"sha256:4994ea39619ce237a116dab2f47261ce8dcfa5924f3aa67ca1305607502e29bc","implementation_owner":null,"merge_gate":null,"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"analyze_code","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/widget/app.py"],"symbols":[],"total_paths":1},"reasons":[],"revision":{"content":"505ec5559236affd51ee6621b0eb54780bcd0e11"},"source":"sha256:818ea7a2a804bfb65dbc824407d2c110ddc22fd91f8236f50242e6a874d86f4a","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}},"title":"Static analysis reported this rule at this location."},{"acceptance":["Inspect the rule and location; reproduce or disposition the finding, then rerun the producer."],"action":"analyze_code","continuation":false,"criterion":"DS-PY-COMPLEXITY","dependencies":[],"eligibility":"READY","evidence":["sha256:05cc5cc2caca768462c0e31b9da37aad7932663e12a10fee8ef127f3b2a1d683","sha256:5090e7b9655fd3d9939f8ba7c919a2659e045d75ce63ea40856ec073dfcce347","sha256:6ef5ce93fdb3c5a8d50b3c7ec29d7866e4074610027af86044b244bce594afe7","sha256:8ebceb314d4dfc405212a596c8710a59dd62d08be813ef28a19406ae5ca201af"],"executor":"worker","fingerprint":"sha256:acb2c75fff57ee03113afb3a6cd6bbba21a84f6451ecc223b0c0b6421244bc95","id":"sha256:cff3578163de2ab37bc8262d21a5bfcfeb152bc102ffc9bbe1af77d787a1086a","implementation_owner":null,"merge_gate":null,"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"analyze_code","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/widget/parser.py"],"symbols":[],"total_paths":1},"reasons":[],"revision":{"content":"505ec5559236affd51ee6621b0eb54780bcd0e11"},"source":"sha256:6f0dd6f4cdb305e29ef81d517d9e3cc0054bb9840ca58acf00905f944c315413","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}},"title":"Static analysis reported this rule at this location."},{"acceptance":["Inspect the rule and location; reproduce or disposition the finding, then rerun the producer."],"action":"analyze_code","continuation":false,"criterion":"DS-PY-IMPORT-CYCLE","dependencies":[],"eligibility":"READY","evidence":["sha256:05cc5cc2caca768462c0e31b9da37aad7932663e12a10fee8ef127f3b2a1d683","sha256:5090e7b9655fd3d9939f8ba7c919a2659e045d75ce63ea40856ec073dfcce347","sha256:6ef5ce93fdb3c5a8d50b3c7ec29d7866e4074610027af86044b244bce594afe7","sha256:8ebceb314d4dfc405212a596c8710a59dd62d08be813ef28a19406ae5ca201af"],"executor":"worker","fingerprint":"sha256:39f69f7b5da647528c5cc1670b8cd97184aea40249c1681c266e25a00ad19d0a","id":"sha256:f4a74d45046ef6bfcf9580aee12d8a0a21a5a78e3cb59054ecc2d33c54f84a2d","implementation_owner":null,"merge_gate":null,"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"analyze_code","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/widget/app.py"],"symbols":[],"total_paths":1},"reasons":[],"revision":{"content":"505ec5559236affd51ee6621b0eb54780bcd0e11"},"source":"sha256:0325428e585c86655dc2b39c618fcdf0f83304edd455cb7e738b4c44f60178e4","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}},"title":"Static analysis reported this rule at this location."}],"kind":"scope","observed_at":"2026-10-08T12:00:00Z","planned_at":"2026-10-08T12:00:00Z","policy_version":"example-1","queues":{"analyze_code":["sha256:18204f3654202a90fbc686d9bcff6e836a1bdd6d723604d2df39be001415d28e","sha256:4994ea39619ce237a116dab2f47261ce8dcfa5924f3aa67ca1305607502e29bc","sha256:cff3578163de2ab37bc8262d21a5bfcfeb152bc102ffc9bbe1af77d787a1086a","sha256:f4a74d45046ef6bfcf9580aee12d8a0a21a5a78e3cb59054ecc2d33c54f84a2d"],"finish_merge":[],"implement_issue":[],"research":[],"review":[]},"selection":{"changes":"OPEN_AND_REGISTERED","issues":[]},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}}} \ No newline at end of file diff --git a/examples/code/work-policy.json b/examples/code/work-policy.json new file mode 100644 index 0000000..8b56348 --- /dev/null +++ b/examples/code/work-policy.json @@ -0,0 +1 @@ +{"actor":"worker","allow_merge_train":false,"contract":"devostasis.work.v2","criteria":[],"dispositions":[],"kind":"policy","minimum_approvals":1,"priorities":{},"queue_order":["review","finish_merge","implement_issue","research","analyze_code"],"read_budget":{"max_bytes":200000,"max_files":20},"requests":[],"required_checks":[],"roles":["all"],"ttl_seconds":3600,"version":"example-1"} \ No newline at end of file diff --git a/examples/work/adoption.json b/examples/work/adoption.json index 26dd285..112f7d2 100644 --- a/examples/work/adoption.json +++ b/examples/work/adoption.json @@ -1 +1 @@ -{"budget":{"max_pages":10,"max_requests":100,"seconds":120,"workers":4},"contract":"devostasis.work-adoption.v1","enabled":false,"engine_version":"0.5.0","integration_id":"widget-shadow-observer","policy":{"actor":"worker","digest":"sha256:271481e214a15c2b1ae112382fadd7019cb7da1d407946ecb115821df81ae8c9","version":"widget-1"},"project":{"endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github"},"selection":{"changes":[],"mode":"REGISTERED"}} \ No newline at end of file +{"budget":{"max_pages":10,"max_requests":100,"seconds":120,"workers":4},"contract":"devostasis.work-adoption.v1","enabled":false,"engine_version":"0.6.0","integration_id":"widget-shadow-observer","policy":{"actor":"worker","digest":"sha256:271481e214a15c2b1ae112382fadd7019cb7da1d407946ecb115821df81ae8c9","version":"widget-1"},"project":{"endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github"},"selection":{"changes":[],"mode":"REGISTERED"}} \ No newline at end of file diff --git a/examples/work/github-observe.yml b/examples/work/github-observe.yml index 6d2433c..309d38c 100644 --- a/examples/work/github-observe.yml +++ b/examples/work/github-observe.yml @@ -1,4 +1,4 @@ -# Copy into an adopter's .github/workflows directory after v0.5.0 is released. +# Copy into an adopter's .github/workflows directory after v0.6.0 is released. name: Work scope on: push: @@ -14,7 +14,7 @@ permissions: checks: read jobs: observe: - uses: drevendev/Devostasis/.github/workflows/work-observe.yml@v0.5.0 + uses: drevendev/Devostasis/.github/workflows/work-observe.yml@v0.6.0 with: policy-path: .devostasis/work-policy.json # This observer is deliberately not a required merge check. Artifacts are diff --git a/examples/work/gitlab-observe.yml b/examples/work/gitlab-observe.yml index 1357e91..3cce859 100644 --- a/examples/work/gitlab-observe.yml +++ b/examples/work/gitlab-observe.yml @@ -2,7 +2,7 @@ # The runner has a mounted private durable volume at WORK_HISTORY_ROOT. Only # protected canonical jobs can write it. It must not be inside the observed repo. variables: - DEVOSTASIS_REF: "v0.5.0" + DEVOSTASIS_REF: "v0.6.0" WORK_POLICY: ".devostasis/work-policy.json" .work-observer: diff --git a/examples/work/gitlab-operations.yml b/examples/work/gitlab-operations.yml index c2f5bc9..affda56 100644 --- a/examples/work/gitlab-operations.yml +++ b/examples/work/gitlab-operations.yml @@ -2,7 +2,7 @@ # authentication, or the adopter's existing DEVOSTASIS_GITLAB_TOKEN environment. # Mount WORK_HISTORY_ROOT as a private durable volume outside the checkout. variables: - DEVOSTASIS_REF: "v0.5.0" + DEVOSTASIS_REF: "v0.6.0" WORK_POLICY: ".devostasis/work-policy.json" WORK_BINDING: ".devostasis/work-adoption.json" diff --git a/pyproject.toml b/pyproject.toml index b8f56a2..956baa1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "devostasis" -version = "0.5.0" -description = "Deterministic, model-free vital signs for software repositories." +version = "0.6.0" +description = "Deterministic repository vital signs, work scopes and offline code analysis." readme = "README.md" license = "MIT" license-files = ["LICENSE"] diff --git a/schemas/code-manifest.schema.json b/schemas/code-manifest.schema.json new file mode 100644 index 0000000..4749c42 --- /dev/null +++ b/schemas/code-manifest.schema.json @@ -0,0 +1,104 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/drevendev/Devostasis/schemas/code-manifest.schema.json", + "description": "Shape constraints; executable admission additionally enforces canonical bytes, source proofs, unique sorted paths, cross-field limits and exact replay.", + "type": "object", + "additionalProperties": false, + "required": [ + "contract", + "engine", + "subject", + "members", + "bundle_id" + ], + "properties": { + "contract": { + "const": "devostasis.code-analysis.v1" + }, + "engine": { + "const": "devostasis.code-engine.v1" + }, + "subject": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "object_format", + "roots", + "repository_id", + "revision", + "tree" + ], + "properties": { + "kind": { + "const": "LOCAL_GIT" + }, + "object_format": { + "enum": [ + "sha1", + "sha256" + ] + }, + "roots": { + "type": "array", + "minItems": 1, + "maxItems": 256, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$" + } + }, + "repository_id": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "revision": { + "type": "string", + "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$" + }, + "tree": { + "type": "string", + "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$" + } + } + }, + "members": { + "type": "object", + "additionalProperties": false, + "required": [ + "inputs.json", + "policy.json", + "analysis.json", + "findings.sarif", + "report.md" + ], + "properties": { + "inputs.json": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "policy.json": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "analysis.json": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "findings.sarif": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "report.md": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + } + } + }, + "bundle_id": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + } + } +} diff --git a/schemas/code-packet.schema.json b/schemas/code-packet.schema.json new file mode 100644 index 0000000..d29e612 --- /dev/null +++ b/schemas/code-packet.schema.json @@ -0,0 +1,138 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/drevendev/Devostasis/schemas/code-packet.schema.json", + "description": "Shape constraints; executable admission additionally enforces canonical bytes, source proofs, unique sorted paths, cross-field limits and exact replay.", + "type": "object", + "additionalProperties": false, + "required": [ + "contract", + "analysis_bundle_id", + "subject", + "budget", + "files", + "total_bytes", + "authority", + "packet_id" + ], + "properties": { + "contract": { + "const": "devostasis.code-packet.v1" + }, + "analysis_bundle_id": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "subject": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "object_format", + "roots", + "repository_id", + "revision", + "tree" + ], + "properties": { + "kind": { + "const": "LOCAL_GIT" + }, + "object_format": { + "enum": [ + "sha1", + "sha256" + ] + }, + "roots": { + "type": "array", + "minItems": 1, + "maxItems": 256, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$" + } + }, + "repository_id": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "revision": { + "type": "string", + "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$" + }, + "tree": { + "type": "string", + "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$" + } + } + }, + "budget": { + "type": "object", + "additionalProperties": false, + "required": [ + "max_files", + "max_bytes" + ], + "properties": { + "max_files": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + "max_bytes": { + "type": "integer", + "minimum": 1, + "maximum": 67108864 + } + } + }, + "files": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "object_id", + "size", + "base64" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[^\\\\\\u0000-\\u001f]+$" + }, + "object_id": { + "type": "string", + "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$" + }, + "size": { + "type": "integer", + "minimum": 0, + "maximum": 67108864 + }, + "base64": { + "type": "string", + "contentEncoding": "base64" + } + } + } + }, + "total_bytes": { + "type": "integer", + "minimum": 0, + "maximum": 67108864 + }, + "authority": { + "const": "PINNED_LOCAL_SOURCE_ONLY; no live freshness, independent acceptance or execution authority" + }, + "packet_id": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + } + } +} diff --git a/schemas/code-policy.schema.json b/schemas/code-policy.schema.json new file mode 100644 index 0000000..ea8133b --- /dev/null +++ b/schemas/code-policy.schema.json @@ -0,0 +1,148 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/drevendev/Devostasis/schemas/code-policy.schema.json", + "description": "Shape constraints; executable admission additionally enforces canonical bytes, source proofs, unique sorted paths, cross-field limits and exact replay.", + "type": "object", + "additionalProperties": false, + "required": [ + "contract", + "version", + "grammar", + "include", + "exclude", + "source_roots", + "budget", + "limits", + "history" + ], + "properties": { + "contract": { + "const": "devostasis.code-policy.v1" + }, + "version": { + "type": "string", + "minLength": 1 + }, + "grammar": { + "const": "PYTHON_3_12" + }, + "include": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[^\\\\\\u0000-\\u001f]+$" + } + }, + "exclude": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[^\\\\\\u0000-\\u001f]+$" + } + }, + "source_roots": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "uniqueItems": true, + "items": { + "anyOf": [ + { + "const": "." + }, + { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[^\\\\\\u0000-\\u001f]+$" + } + ] + } + }, + "budget": { + "type": "object", + "additionalProperties": false, + "required": [ + "max_files", + "max_file_bytes", + "max_total_bytes", + "seconds" + ], + "properties": { + "max_files": { + "type": "integer", + "minimum": 1, + "maximum": 5000 + }, + "max_file_bytes": { + "type": "integer", + "minimum": 1, + "maximum": 4194304 + }, + "max_total_bytes": { + "type": "integer", + "minimum": 1, + "maximum": 67108864 + }, + "seconds": { + "type": "integer", + "minimum": 1, + "maximum": 3600 + } + } + }, + "limits": { + "type": "object", + "additionalProperties": false, + "required": [ + "complexity", + "function_lines", + "file_lines" + ], + "properties": { + "complexity": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + }, + "function_lines": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + }, + "file_lines": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + } + } + }, + "history": { + "type": "object", + "additionalProperties": false, + "required": [ + "enabled", + "days", + "max_commits" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "days": { + "type": "integer", + "minimum": 1, + "maximum": 3650 + }, + "max_commits": { + "type": "integer", + "minimum": 1, + "maximum": 10000 + } + } + } + } +} diff --git a/scripts/make_code_examples.py b/scripts/make_code_examples.py new file mode 100644 index 0000000..609f014 --- /dev/null +++ b/scripts/make_code_examples.py @@ -0,0 +1,69 @@ +"""Regenerate synthetic native analysis and its analyze_code work queue offline.""" + +from copy import deepcopy +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) +from devostasis.canonical import canonical_bytes +from devostasis.codeanalysis.bundle import build, verify_content +from devostasis.codeanalysis.consumer import packet, work_evidence +from devostasis.codeanalysis.engine import default_policy +from devostasis.codeanalysis.git import collect +from devostasis.workscope.bundle import build as build_work, verify_content as verify_work +from devostasis.workscope.collect import complete +from devostasis.workscope.model import default_policy as work_policy + +ROOT = Path(__file__).resolve().parents[1] +AT = "2026-10-08T12:00:00Z" + + +def generate(): + config = default_policy() + config["limits"]["complexity"] = 1 + with tempfile.TemporaryDirectory(prefix="devostasis-synthetic-code-") as directory: + repo = Path(directory) + environment = {**{k: v for k, v in os.environ.items() if k.upper() in {"PATH", "SYSTEMROOT", "TEMP", "TMP", "HOME", "USERPROFILE"}}, + "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_AUTHOR_NAME": "Synthetic Fixture", "GIT_COMMITTER_NAME": "Synthetic Fixture", + "GIT_AUTHOR_EMAIL": "fixture@example.invalid", "GIT_COMMITTER_EMAIL": "fixture@example.invalid", + "GIT_AUTHOR_DATE": AT, "GIT_COMMITTER_DATE": AT} + def git(*args): + return subprocess.run(["git", "-c", "commit.gpgSign=false", "-c", "core.autocrlf=false", *args], + cwd=repo, env=environment, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout + git("init", "--initial-branch=main", "--object-format=sha1") + files = {"src/widget/__init__.py": "", "src/widget/app.py": "from . import parser\n\ndef choose(value):\n if value:\n return 1\n return 0\n", + "src/widget/parser.py": "from . import app\n\ndef fallback():\n try:\n return 1\n except:\n return 0\n", "README.md": "Synthetic source fixture.\n"} + for filename, data in files.items(): + file = repo / filename; file.parent.mkdir(parents=True, exist_ok=True); file.write_bytes(data.encode("utf-8")) + git("add", "--all"); git("commit", "-m", "Synthetic offline analysis fixture") + raw = collect(repo, "HEAD", config, AT) + _, content = build(raw, config) + decoded = verify_content(content) + inv = deepcopy(json.loads((ROOT / "examples/work/inventory.json").read_text(encoding="utf-8"))) + inv["subject"]["revision"] = raw["subject"]["revision"] + inv["subject"]["source_binding"]["revision"] = raw["subject"]["revision"] + inv.update(observed_at=AT, changes=complete(), issues=complete(), receipts=[], + selection={"changes": "OPEN_AND_REGISTERED", "issues": []}) + evidence = work_evidence(decoded, inv) + consumer_policy = work_policy("worker") + _, consumer_content = build_work(inv, consumer_policy, evidence) + scope = verify_work(consumer_content)["scope.json"] + assert any(item["queue"] == "analyze_code" for item in scope["items"]) + output = {"policy.json": canonical_bytes(config), "packet.json": canonical_bytes(packet(decoded, ["src/widget/app.py"])), + "inventory.json": canonical_bytes(inv), "work-policy.json": canonical_bytes(consumer_policy), + "evidence.json": canonical_bytes(evidence)} + output.update({"bundle/" + name: data for name, data in content.items()}) + output.update({"work-bundle/" + name: data for name, data in consumer_content.items()}) + return output + + +if __name__ == "__main__": + destination = ROOT / "examples/code" + for name, data in generate().items(): + file = destination / name; file.parent.mkdir(parents=True, exist_ok=True); file.write_bytes(data) + print(destination) diff --git a/src/devostasis/__init__.py b/src/devostasis/__init__.py index 4862142..9a0dad9 100644 --- a/src/devostasis/__init__.py +++ b/src/devostasis/__init__.py @@ -1,3 +1,3 @@ """Devostasis: deterministic, model-free vital signs for software repositories.""" -__version__ = "0.5.0" +__version__ = "0.6.0" diff --git a/src/devostasis/cli.py b/src/devostasis/cli.py index d6fe010..6b6cea0 100644 --- a/src/devostasis/cli.py +++ b/src/devostasis/cli.py @@ -521,6 +521,8 @@ def build_parser() -> argparse.ArgumentParser: summary_p.set_defaults(func=cmd_actions_summary) from .workscope.cli import add_parser add_parser(sub) + from .codeanalysis.cli import add_parser as add_code_parser + add_code_parser(sub) return parser diff --git a/src/devostasis/codeanalysis/__init__.py b/src/devostasis/codeanalysis/__init__.py new file mode 100644 index 0000000..20e070c --- /dev/null +++ b/src/devostasis/codeanalysis/__init__.py @@ -0,0 +1,7 @@ +"""Offline, revision-bound source evidence; independent of the seven Vitals.""" + +CONTRACT = "devostasis.code-analysis.v1" +POLICY = "devostasis.code-policy.v1" +ENGINE = "devostasis.code-engine.v1" +PACKET = "devostasis.code-packet.v1" +COMPARISON = "devostasis.code-comparison.v1" diff --git a/src/devostasis/codeanalysis/bundle.py b/src/devostasis/codeanalysis/bundle.py new file mode 100644 index 0000000..ca84c37 --- /dev/null +++ b/src/devostasis/codeanalysis/bundle.py @@ -0,0 +1,228 @@ +"""Canonical analysis artifacts with exact offline replay and Git source proofs.""" + +import base64 +from copy import deepcopy +from datetime import timedelta +import html +import os +from pathlib import Path +import re +import shutil +import tempfile +from urllib.parse import quote + +from ..canonical import canonical_bytes, digest, digest_bytes, loads +from ..workscope.bundle import locked +from ..workscope.model import fields, instant, number, path, require, revision, strings, timestamp +from ..workscope.operations import no_links +from . import CONTRACT, ENGINE +from .engine import RULES, evaluate, policy +from .git import object_digest + +MEMBERS = ("inputs.json", "policy.json", "analysis.json", "findings.sarif", "report.md") +MAX_BUNDLE_BYTES = 128 * 1024 * 1024 + + +def decode(value, cap): + require(isinstance(value, str) and len(value) <= (cap + 2) // 3 * 4, "source encoding byte cap") + try: data = base64.b64decode(value, validate=True) + except ValueError as exc: raise ValueError("source base64 unavailable") from exc + require(len(data) <= cap and base64.b64encode(data).decode("ascii") == value, "noncanonical source base64") + return data + + +def admit(raw, config): + policy(config) + fields(raw, ("subject", "observed_at", "entries", "sources", "history", "proof")) + subject = raw["subject"] + fields(subject, ("kind", "object_format", "roots", "repository_id", "revision", "tree")) + require(subject["kind"] == "LOCAL_GIT" and subject["object_format"] in ("sha1", "sha256"), "unsupported local source identity") + width = 40 if subject["object_format"] == "sha1" else 64 + for ref in (subject["revision"], subject["tree"]): + revision(ref); require(len(ref) == width, "Git object format mismatch") + strings(subject["roots"], "root commits") + require(0 < len(subject["roots"]) <= 256 and subject["roots"] == sorted(subject["roots"]), "noncanonical root identity") + for ref in subject["roots"]: revision(ref); require(len(ref) == width, "root format mismatch") + require(subject["repository_id"] == digest({"object_format": subject["object_format"], "roots": subject["roots"]}), "repository identity mismatch") + instant(raw["observed_at"]) + fields(raw["proof"], ("commit", "trees")) + commit = decode(raw["proof"]["commit"], 1024 * 1024) + require(object_digest(commit, subject["object_format"], "commit") == subject["revision"], "commit proof mismatch") + tree_headers = [line[5:] for line in commit.split(b"\n\n", 1)[0].splitlines() if line.startswith(b"tree ")] + require(tree_headers == [subject["tree"].encode("ascii")], "commit tree binding mismatch") + trees = raw["proof"]["trees"] + require(isinstance(trees, dict) and 0 < len(trees) <= 10000, "tree proof unavailable") + tree_bytes, total = {}, 0 + for oid, encoded in trees.items(): + revision(oid); require(len(oid) == width, "tree format mismatch") + data = decode(encoded, 8 * 1024 * 1024); total += len(data) + require(total <= 8 * 1024 * 1024 and object_digest(data, subject["object_format"], "tree") == oid, "tree proof mismatch or byte cap") + tree_bytes[oid] = data + expected, visited = {}, set() + stack = [(subject["tree"], b"", frozenset())] + while stack: + oid, prefix, parents = stack.pop() + require(oid in tree_bytes and oid not in parents, "tree proof missing or cyclic") + visited.add(oid); data = tree_bytes[oid]; offset = 0 + while offset < len(data): + space = data.find(b" ", offset); end = data.find(b"\0", space) + require(space > offset and end > space + 1 and end + 1 + width // 2 <= len(data), "tree entry malformed") + mode, name = data[offset:space], data[space + 1:end] + require(b"/" not in name and name not in (b".", b".."), "tree name malformed") + child = data[end + 1:end + 1 + width // 2].hex(); offset = end + 1 + width // 2 + filename = prefix + name + if mode == b"40000": + stack.append((child, filename + b"/", parents | {oid})) + else: + require(mode in (b"100644", b"100755", b"120000", b"160000") and filename not in expected, "tree entry mode or duplicate path") + expected[filename] = (mode.decode("ascii"), "commit" if mode == b"160000" else "blob", child) + require(len(expected) <= 100000 and len(stack) <= 10000, "tree expansion cap") + require(visited == set(trees), "unused tree proof") + require(isinstance(raw["entries"], list) and len(raw["entries"]) == len(expected), "source inventory incomplete") + require(isinstance(raw["sources"], dict), "source map unavailable") + seen, admitted, source_total = [], set(), 0 + for entry in raw["entries"]: + fields(entry, ("path", "path_base64", "mode", "kind", "object_id", "size", "status", "reason")) + filename = decode(entry["path_base64"], 65536); seen.append(filename) + require(filename in expected and (entry["mode"], entry["kind"], entry["object_id"]) == expected[filename], "inventory tree binding mismatch") + safe = None + try: safe = path(filename.decode("utf-8")) + except (ValueError, UnicodeError): pass + require(entry["path"] == safe, "inventory path decoding mismatch") + require(entry["status"] in ("ADMITTED", "UNAVAILABLE", "UNSUPPORTED", "OUT_OF_SCOPE"), "inventory availability unknown") + if entry["kind"] == "blob": number(entry["size"], 0, 2**63 - 1) + else: require(entry["size"] is None, "submodule must not claim blob size") + included = safe is not None and (not config["include"] or any(safe == p or safe.startswith(p + "/") for p in config["include"])) + excluded = safe is not None and any(safe == p or safe.startswith(p + "/") for p in config["exclude"]) + if entry["status"] == "ADMITTED": + require(included and not excluded and safe.endswith(".py") and entry["mode"] in ("100644", "100755") and entry["reason"] is None, "source scope mismatch") + require(safe in raw["sources"], "admitted source missing") + data = decode(raw["sources"][safe], config["budget"]["max_file_bytes"]) + require(len(data) == entry["size"] and object_digest(data, subject["object_format"]) == entry["object_id"], "source blob binding mismatch") + admitted.add(safe); source_total += len(data) + elif entry["status"] == "OUT_OF_SCOPE": require(safe is not None and (not included or excluded) and entry["reason"] == "POLICY_EXCLUSION", "unjustified scope exclusion") + elif entry["status"] == "UNSUPPORTED": require(included and not excluded and not safe.endswith(".py") and entry["mode"] in ("100644", "100755") and entry["reason"] == "LANGUAGE_UNSUPPORTED", "unsupported source mismatch") + else: + require(entry["reason"] in ("UNSAFE_PATH", "SOURCE_LINK_OR_SUBMODULE", "FILE_BYTE_CAP", "FILE_COUNT_CAP", "TOTAL_BYTE_CAP"), "unavailable source reason unknown") + require(seen == sorted(set(seen)) and set(raw["sources"]) == admitted, "duplicate, unordered or foreign source") + require(len(admitted) <= config["budget"]["max_files"] and source_total <= config["budget"]["max_total_bytes"], "source budget exceeded") + history = raw["history"] + fields(history, ("status", "reasons", "start", "end", "commits")); strings(history["reasons"], "history reasons") + require(instant(history["end"]) == instant(raw["observed_at"]) and instant(history["start"]) == instant(history["end"]) - timedelta(days=config["history"]["days"]), "history window mismatch") + require(history["status"] in ("COMPLETE", "PARTIAL", "UNAVAILABLE", "NOT_REQUESTED"), "history availability unknown") + require((history["status"] == "NOT_REQUESTED") == (not config["history"]["enabled"]), "history enablement mismatch") + require(isinstance(history["commits"], list) and len(history["commits"]) <= config["history"]["max_commits"], "history commit cap") + require((history["status"] in ("PARTIAL", "UNAVAILABLE")) == bool(history["reasons"]), "history availability reason mismatch") + require(history["status"] not in ("UNAVAILABLE", "NOT_REQUESTED") or not history["commits"], "unavailable history claims commits") + refs = [] + for row in history["commits"]: + fields(row, ("revision", "committed_at", "changes")); revision(row["revision"]); refs.append(row["revision"]) + require(len(row["revision"]) == width and instant(history["start"]) <= instant(row["committed_at"]) <= instant(history["end"]), "history revision/time mismatch") + require(isinstance(row["changes"], list) and len(row["changes"]) <= 100000, "history change cap") + paths = [] + for change in row["changes"]: + fields(change, ("path", "added", "deleted")); path(change["path"]); paths.append(change["path"]) + require((change["added"] is None) == (change["deleted"] is None), "binary change mismatch") + if change["added"] is not None: + number(change["added"], 0, 2**63 - 1); number(change["deleted"], 0, 2**63 - 1) + require(paths == sorted(set(paths)), "duplicate history paths") + require(len(refs) == len(set(refs)) and history["commits"] == sorted(history["commits"], key=lambda c: (c["committed_at"], c["revision"])), "duplicate or unordered history") + return raw + + +def escape(value): + return re.sub(r"([\\`*_{}\[\]()#+.!|])", r"\\\1", html.escape(str(value), quote=True)) + + +def sarif(analysis, *, encode_paths=True): + return {"version": "2.1.0", "$schema": "https://json.schemastore.org/sarif-2.1.0.json", "runs": [{ + "tool": {"driver": {"name": "Devostasis Code Analysis", "version": ENGINE, + "rules": [{"id": r, "shortDescription": {"text": m}} for r, m in sorted(RULES.items())]}}, + "versionControlProvenance": [{"revisionId": analysis["subject"]["revision"]}], + "properties": {"sourceCoverage": analysis["coverage"], "historyCoverage": analysis["history"]["status"], "authority": analysis["authority"]}, + "results": [{"ruleId": f["rule"], "level": f["level"], "message": {"text": f["message"]}, + "partialFingerprints": {"devostasisFindingId": f["id"]}, + "locations": [{"physicalLocation": {"artifactLocation": {"uri": quote(f["path"], safe="/") if encode_paths else f["path"]}, + "region": {"startLine": f["line"], "endLine": f["end_line"]}}}], + "properties": {"symbol": f["symbol"], "evidence": f["evidence"], "relatedPaths": f["related_paths"]}} + for f in analysis["findings"]]}]} + + +def render(analysis): + counts = {s: sum(f["status"] == s for f in analysis["files"]) for s in + ("ANALYZED", "PARSE_ERROR", "UNAVAILABLE", "UNSUPPORTED", "OUT_OF_SCOPE")} + lines = ["# Offline repository analysis", "", "Revision: `" + analysis["subject"]["revision"] + "`", + "", "Python grammar: 3.12. Static evidence only; repository code was not executed.", + "", "Python source coverage: " + analysis["coverage"] + "; history: " + analysis["history"]["status"] + ".", + "", "Unsupported and unavailable inputs do not establish an absence of findings.", "", + "| Source status | Files |", "| --- | ---: |", *[f"| {s} | {n} |" for s, n in counts.items()], + "", "## Findings", "", "| Path | Line | Rule | Symbol | Evidence |", "| --- | ---: | --- | --- | --- |"] + lines += [f"| {escape(f['path'])} | {f['line']} | {f['rule']} | {escape(f['symbol'])} | {escape(canonical_bytes(f['evidence']).decode('utf-8'))} |" for f in analysis["findings"][:100]] + lines += ["", f"Showing {min(100, len(analysis['findings']))} of {len(analysis['findings'])} findings; analysis.json and findings.sarif retain every finding.", + "", "## Source gaps", "", "| Path | Status | Reason |", "| --- | --- | --- |"] + gaps = [f for f in analysis["files"] if f["status"] in ("UNAVAILABLE", "PARSE_ERROR")] + lines += [f"| {escape(f['path'] or '[unsafe path]')} | {f['status']} | {f['reason']} |" for f in gaps[:100]] + lines += ["", f"Showing {min(100, len(gaps))} of {len(gaps)} source gaps.", + "", "## Recent change evidence", "", "Non-merge commits in [" + analysis["history"]["start"] + ", " + analysis["history"]["end"] + "].", + "", "Counts under PARTIAL history are observed counts, not complete totals or productivity.", "", + "| Path | Revisions | Added | Deleted | Coverage |", "| --- | ---: | ---: | ---: | --- |"] + lines += [f"| {escape(h['path'])} | {h['revisions']} | {h['added']} | {h['deleted']} | {h['coverage']} |" for h in analysis["hotspots"][:20]] + lines += ["", f"Showing {min(20, len(analysis['hotspots']))} of {len(analysis['hotspots'])} paths.", "", analysis["authority"], ""] + return "\n".join(lines).encode("utf-8") + + +def build(raw, config): + admit(raw, config); analysis = evaluate(raw, config) + content = {"inputs.json": canonical_bytes(raw), "policy.json": canonical_bytes(config), "analysis.json": canonical_bytes(analysis), + "findings.sarif": canonical_bytes(sarif(analysis)), "report.md": render(analysis)} + manifest = {"contract": CONTRACT, "engine": ENGINE, "subject": deepcopy(raw["subject"]), + "members": {name: digest_bytes(data) for name, data in sorted(content.items())}} + manifest["bundle_id"] = digest(manifest); content["manifest.json"] = canonical_bytes(manifest) + require(sum(map(len, content.values())) <= MAX_BUNDLE_BYTES, "analysis bundle byte cap") + return manifest, content + + +def verify_content(content): + require(set(content) == set(MEMBERS) | {"manifest.json"} and sum(map(len, content.values())) <= MAX_BUNDLE_BYTES, "analysis member set/byte cap") + decoded = {name: loads(data.decode("utf-8")) for name, data in content.items() if name != "report.md"} + require(all(canonical_bytes(value) == content[name] for name, value in decoded.items()), "analysis member is not canonical") + manifest = decoded["manifest.json"] + fields(manifest, ("contract", "engine", "subject", "members", "bundle_id")) + require(manifest["contract"] == CONTRACT and manifest["engine"] == ENGINE, "unsupported analysis lineage") + expected, replay = build(decoded["inputs.json"], decoded["policy.json"]) + require(expected == manifest and replay == content, "analysis bundle does not replay") + return decoded + + +def read(root): + root = no_links(root) + require(root.is_dir() and {p.name for p in root.iterdir()} == set(MEMBERS) | {"manifest.json"}, "analysis bundle directory/member mismatch") + total, content = 0, {} + for name in (*MEMBERS, "manifest.json"): + file = no_links(root / name) + require(file.is_file(), "analysis member is not a regular file") + total += file.stat().st_size; require(total <= MAX_BUNDLE_BYTES, "analysis bundle byte cap") + content[name] = file.read_bytes() + return content + + +def verify(root): + return verify_content(read(root)) + + +def publish(root, content): + verify_content(content); root = no_links(root) + root.parent.mkdir(parents=True, exist_ok=True); no_links(root.parent) + with locked(root.parent / ("." + root.name + ".code-lock")): + if root.exists(): + require(read(root) == content, "analysis destination immutable collision") + return root + pending = Path(tempfile.mkdtemp(prefix="." + root.name + ".pending-", dir=root.parent)) + try: + for name, data in content.items(): (pending / name).write_bytes(data) + os.replace(pending, root) + finally: + if pending.exists(): + require(pending.resolve().parent == root.parent.resolve(), "pending cleanup outside output parent") + shutil.rmtree(pending) + return root diff --git a/src/devostasis/codeanalysis/cli.py b/src/devostasis/codeanalysis/cli.py new file mode 100644 index 0000000..c053de9 --- /dev/null +++ b/src/devostasis/codeanalysis/cli.py @@ -0,0 +1,72 @@ +"""Offline code analysis commands. Source bytes remain in the chosen local output.""" + +from pathlib import Path +import sys + +from ..canonical import canonical_bytes, loads +from ..workscope.model import require +from .bundle import MAX_BUNDLE_BYTES, build, publish, verify +from .consumer import compare, packet, verify_packet, work_evidence +from .engine import default_policy +from .git import collect + + +def json_file(filename): + file = Path(filename) + require(file.is_file() and file.stat().st_size <= MAX_BUNDLE_BYTES, "analysis input missing or byte cap") + return loads(file.read_text(encoding="utf-8")) + + +def emit(value, filename=None): + data = canonical_bytes(value) + if filename: + from ..workscope.operations import no_links + file = no_links(filename); file.parent.mkdir(parents=True, exist_ok=True); no_links(file) + from ..workscope.bundle import atomic + atomic(file, data) + else: print(data.decode("utf-8")) + + +def cmd(args): + try: + if args.operation == "policy": emit(default_policy(), args.output); return 0 + if args.operation == "analyze": + config = json_file(args.policy) if args.policy else default_policy() + from .engine import policy + policy(config) + raw = collect(args.repo, args.revision, config, args.at) + manifest, members = build(raw, config); root = publish(args.output, members) + emit({"status": "PUBLISHED", "bundle_id": manifest["bundle_id"], "path": str(root.resolve()), + "coverage": verify(root)["analysis.json"]["coverage"]}) + return 0 + if args.operation == "compare": + emit(compare(verify(args.before), verify(args.after)), args.output); return 0 + decoded = verify(args.bundle) + if args.operation == "verify": emit({"status": "VERIFIED", "bundle_id": decoded["manifest.json"]["bundle_id"]}) + elif args.operation == "show": emit(decoded["analysis.json"], args.output) + elif args.operation == "sarif": emit(decoded["findings.sarif"], args.output) + elif args.operation == "packet": emit(packet(decoded, args.path, args.max_files, args.max_bytes), args.output) + elif args.operation == "verify-packet": + value = verify_packet(json_file(args.packet), decoded); emit({"status": "VERIFIED", "packet_id": value["packet_id"]}) + elif args.operation == "work-evidence": emit(work_evidence(decoded, json_file(args.inventory), args.at), args.output) + return 0 + except (ValueError, KeyError, TypeError, OSError, RecursionError, OverflowError) as exc: + print(f"code: {type(exc).__name__}: {exc}", file=sys.stderr); return 2 + + +def add_parser(parent): + root = parent.add_parser("code", help="offline revision-bound Python analysis, Git history and source packets") + commands = root.add_subparsers(dest="operation", required=True) + for operation in ("policy", "analyze", "verify", "show", "sarif", "compare", "packet", "verify-packet", "work-evidence"): + parser = commands.add_parser(operation); parser.set_defaults(func=cmd) + if operation not in ("policy", "analyze", "compare"): parser.add_argument("--bundle", required=True) + if operation == "analyze": + parser.add_argument("--repo", default="."); parser.add_argument("--revision", default="HEAD") + parser.add_argument("--policy"); parser.add_argument("--at") + if operation == "compare": parser.add_argument("--before", required=True); parser.add_argument("--after", required=True) + if operation == "packet": + parser.add_argument("--path", action="append", required=True) + parser.add_argument("--max-files", type=int, default=20); parser.add_argument("--max-bytes", type=int, default=1024 * 1024) + if operation == "verify-packet": parser.add_argument("--packet", required=True) + if operation == "work-evidence": parser.add_argument("--inventory", required=True); parser.add_argument("--at") + if operation not in ("verify", "verify-packet"): parser.add_argument("--output", required=operation == "analyze") diff --git a/src/devostasis/codeanalysis/consumer.py b/src/devostasis/codeanalysis/consumer.py new file mode 100644 index 0000000..07ec5d4 --- /dev/null +++ b/src/devostasis/codeanalysis/consumer.py @@ -0,0 +1,90 @@ +"""Conservative comparisons, bounded source packets and explicit forge binding.""" + +import base64 +from copy import deepcopy + +from ..canonical import canonical_bytes, digest +from ..workscope.model import fields, instant, number, path, require, strings +from . import COMPARISON, ENGINE, PACKET +from .bundle import decode, sarif + + +def compare(before, after): + a, b = before["analysis.json"], after["analysis.json"] + reasons = [] + if a["subject"]["repository_id"] != b["subject"]["repository_id"]: reasons.append("LOCAL_REPOSITORY_IDENTITY_CHANGED") + if before["policy.json"] != after["policy.json"]: reasons.append("ANALYSIS_POLICY_CHANGED") + result = {"contract": COMPARISON, "before": before["manifest.json"]["bundle_id"], "after": after["manifest.json"]["bundle_id"], + "status": "INCOMPARABLE" if reasons else "COMPARABLE", "reasons": reasons, "findings": [], "files": [], + "authority": "STATIC_FINDING_TRANSITIONS; no health, productivity, improvement or acceptance judgement"} + if reasons: return result + old_files = {f["path"]: f for f in a["files"] if f["path"] is not None} + new_files = {f["path"]: f for f in b["files"] if f["path"] is not None} + old = {f["id"]: f for f in a["findings"]}; new = {f["id"]: f for f in b["findings"]} + def known(files, finding): + return all(p not in files or files[p]["status"] == "ANALYZED" for p in [finding["path"], *finding["related_paths"]]) + for fid in sorted(set(old) | set(new)): + if fid in old and fid in new: + state = "PERSISTING" if old[fid] == new[fid] else "CHANGED" + elif fid in new: + state = "NEW" if known(old_files, new[fid]) else "FIRST_OBSERVED" + else: + state = "RESOLVED" if known(new_files, old[fid]) else "UNOBSERVED" + result["findings"].append({"id": fid, "state": state, "before": old.get(fid), "after": new.get(fid)}) + for filename in sorted(set(old_files) | set(new_files)): + old_file, new_file = old_files.get(filename), new_files.get(filename) + state = "ADDED" if old_file is None else "REMOVED" if new_file is None else "UNCHANGED" if old_file["object_id"] == new_file["object_id"] else "CHANGED" + result["files"].append({"path": filename, "state": state, "before_status": old_file["status"] if old_file else "ABSENT", + "after_status": new_file["status"] if new_file else "ABSENT"}) + return result + + +def packet(decoded, paths, max_files=20, max_bytes=1024 * 1024): + strings(paths, "packet paths", paths=True) + number(max_files, 1, 1000); number(max_bytes, 1, 64 * 1024 * 1024) + require(0 < len(paths) <= max_files, "source packet file budget") + raw = decoded["inputs.json"]; entries = {e["path"]: e for e in raw["entries"] if e["path"] is not None} + files, total = [], 0 + for filename in sorted(paths): + require(filename in raw["sources"] and entries[filename]["status"] == "ADMITTED", "source packet path unavailable") + data = decode(raw["sources"][filename], max_bytes); total += len(data) + require(total <= max_bytes, "source packet byte budget") + files.append({"path": filename, "object_id": entries[filename]["object_id"], "size": len(data), "base64": raw["sources"][filename]}) + value = {"contract": PACKET, "analysis_bundle_id": decoded["manifest.json"]["bundle_id"], "subject": deepcopy(raw["subject"]), + "budget": {"max_files": max_files, "max_bytes": max_bytes}, "files": files, "total_bytes": total, + "authority": "PINNED_LOCAL_SOURCE_ONLY; no live freshness, independent acceptance or execution authority"} + value["packet_id"] = digest(value) + return value + + +def verify_packet(value, decoded): + fields(value, ("contract", "analysis_bundle_id", "subject", "budget", "files", "total_bytes", "authority", "packet_id")) + fields(value["budget"], ("max_files", "max_bytes")) + require(isinstance(value["files"], list), "packet files unavailable") + for file in value["files"]: fields(file, ("path", "object_id", "size", "base64")); path(file["path"]) + expected = packet(decoded, [f["path"] for f in value["files"]], **value["budget"]) + require(value == expected, "source packet does not match admitted analysis") + return value + + +def work_evidence(decoded, inventory, at=None): + from ..workscope.evidence import empty_evidence, source, validate + from ..workscope.model import inventory as admit_inventory + admit_inventory(inventory) + require(decoded["analysis.json"]["subject"]["revision"] == inventory["subject"]["revision"], "analysis/work revision mismatch") + # The caller supplies an already admitted forge inventory. Local roots or a + # remote URL never stand in for an immutable provider project identity. + analysis = decoded["analysis.json"] + observed = analysis["observed_at"] + require(at is None or instant(at) == instant(observed), "export cannot change analysis observation time") + # Existing sarif.v1 admission defines URI strings as repository-relative + # source paths. Preserve that historical profile, while the standalone + # SARIF artifact uses encoded URI references for external consumers. + report = canonical_bytes(sarif(analysis, encode_paths=False)) + value = empty_evidence() + entry = source("sarif.v1", ENGINE, "1", deepcopy(inventory["subject"]), observed, report) + if analysis["coverage"] != "COMPLETE": + entry.update(status="PARTIAL", reason="CODE_SOURCE_COVERAGE_" + analysis["coverage"]) + value["sources"].append(entry) + validate(value) + return value diff --git a/src/devostasis/codeanalysis/engine.py b/src/devostasis/codeanalysis/engine.py new file mode 100644 index 0000000..11ac2b4 --- /dev/null +++ b/src/devostasis/codeanalysis/engine.py @@ -0,0 +1,210 @@ +"""Pure Python AST and import evidence, evaluated without importing repository code.""" + +import ast +import base64 +from collections import Counter, defaultdict +from copy import deepcopy +import io +import tokenize + +from ..canonical import digest +from ..workscope.model import fields, number, path, require, strings +from . import ENGINE, POLICY + +RULES = {"DS-PY-SYNTAX": "Python syntax cannot be parsed", "DS-PY-COMPLEXITY": "Function decision count exceeds policy", + "DS-PY-FUNCTION-SIZE": "Function line span exceeds policy", "DS-PY-FILE-SIZE": "File line count exceeds policy", + "DS-PY-BARE-EXCEPT": "Bare except also catches process control exceptions", "DS-PY-IMPORT-CYCLE": "Static import cycle"} + + +def default_policy(): + return {"contract": POLICY, "version": "default-1", "grammar": "PYTHON_3_12", "include": [], + "exclude": [".venv", "build", "dist", "vendor", "node_modules"], "source_roots": [".", "src"], + "budget": {"max_files": 2000, "max_file_bytes": 1024 * 1024, "max_total_bytes": 16 * 1024 * 1024, "seconds": 120}, + "limits": {"complexity": 15, "function_lines": 80, "file_lines": 600}, + "history": {"enabled": True, "days": 28, "max_commits": 500}} + + +def policy(value): + fields(value, ("contract", "version", "grammar", "include", "exclude", "source_roots", "budget", "limits", "history")) + require(value["contract"] == POLICY and value["grammar"] == "PYTHON_3_12", "unsupported code policy") + from ..workscope.model import text + text(value["version"]) + strings(value["include"], "included roots", paths=True); strings(value["exclude"], "excluded roots", paths=True) + strings(value["source_roots"], "source roots") + require(value["source_roots"] and len(value["source_roots"]) <= 100, "source roots required") + for root in value["source_roots"]: + if root != ".": path(root) + fields(value["budget"], ("max_files", "max_file_bytes", "max_total_bytes", "seconds")) + for key, high in (("max_files", 5000), ("max_file_bytes", 4 * 1024 * 1024), ("max_total_bytes", 64 * 1024 * 1024), ("seconds", 3600)): + number(value["budget"][key], 1, high) + require(value["budget"]["max_file_bytes"] <= value["budget"]["max_total_bytes"], "per-file budget exceeds total") + fields(value["limits"], ("complexity", "function_lines", "file_lines")) + for v in value["limits"].values(): number(v, 1, 100000) + fields(value["history"], ("enabled", "days", "max_commits")) + require(type(value["history"]["enabled"]) is bool, "explicit history enablement required") + number(value["history"]["days"], 1, 3650); number(value["history"]["max_commits"], 1, 10000) + return value + + +def local_nodes(node): + stack = list(node.body) + while stack: + child = stack.pop() + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)): + continue + yield child + stack.extend(ast.iter_child_nodes(child)) + + +def decisions(node): + result = 1 + for child in local_nodes(node): + if isinstance(child, (ast.If, ast.For, ast.AsyncFor, ast.While, ast.IfExp, ast.ExceptHandler)): result += 1 + elif isinstance(child, ast.BoolOp): result += len(child.values) - 1 + elif isinstance(child, ast.comprehension): result += 1 + len(child.ifs) + elif isinstance(child, ast.Match): result += max(0, len(child.cases) - 1) + return result + + +def module_name(filename, roots): + eligible = [r for r in roots if r == "." or filename.startswith(r + "/")] + if not eligible: return None + root = max(eligible, key=lambda r: 0 if r == "." else len(r)) + name = filename if root == "." else filename[len(root) + 1:] + parts = name[:-3].split("/") + if parts[-1] == "__init__": parts.pop() + return ".".join(parts) if parts and all(p.isidentifier() for p in parts) else None + + +def cycles(nodes, edges): + # Iterative reachability avoids recursion limits on repositories with long chains. + graph, reverse = defaultdict(set), defaultdict(set) + for edge in edges: + if edge["status"] == "RESOLVED": + graph[edge["from"]].add(edge["to"]); reverse[edge["to"]].add(edge["from"]) + visited, order = set(), [] + for start in sorted(nodes): + if start in visited: continue + stack = [(start, False)] + while stack: + node, done = stack.pop() + if done: order.append(node); continue + if node in visited: continue + visited.add(node); stack.append((node, True)) + stack.extend((n, False) for n in sorted(graph[node], reverse=True) if n not in visited) + assigned, components = set(), [] + for start in reversed(order): + if start in assigned: continue + component, stack = [], [start] + while stack: + node = stack.pop() + if node in assigned: continue + assigned.add(node); component.append(node); stack.extend(reverse[node] - assigned) + component.sort() + if len(component) > 1 or start in graph[start]: components.append(component) + return sorted(components) + + +def evaluate(raw, config): + policy(config) + output, findings, imports = [], [], [] + entries = {e["path"]: e for e in raw["entries"] if e["path"] is not None} + module_paths = defaultdict(list) + for filename in entries: + if filename.endswith(".py"): + name = module_name(filename, config["source_roots"]) + if name: module_paths[name].append(filename) + + def finding(rule, filename, symbol, line, end, evidence, related=()): + findings.append({"id": digest({"rule": rule, "path": filename, "symbol": symbol}), "rule": rule, + "level": "error" if rule == "DS-PY-SYNTAX" else "warning", "path": filename, "symbol": symbol, + "line": line, "end_line": end, "message": RULES[rule], "evidence": evidence, "related_paths": sorted(related)}) + + for entry in raw["entries"]: + row = {"path": entry["path"], "object_id": entry["object_id"], "status": entry["status"], "reason": entry["reason"], + "module": module_name(entry["path"], config["source_roots"]) if entry["path"] and entry["path"].endswith(".py") else None, + "lines": None, "functions": []} + output.append(row) + if entry["status"] != "ADMITTED": continue + filename = entry["path"]; data = base64.b64decode(raw["sources"][filename], validate=True) + try: + encoding, _ = tokenize.detect_encoding(io.BytesIO(data).readline) + source = data.decode(encoding) + except (SyntaxError, UnicodeError, LookupError): + row.update(status="UNAVAILABLE", reason="SOURCE_ENCODING_UNAVAILABLE"); continue + row["lines"] = len(source.splitlines()) + try: tree = ast.parse(source, filename=filename, feature_version=(3, 12)) + except (SyntaxError, ValueError, RecursionError): + row.update(status="PARSE_ERROR", reason="PYTHON_3_12_SYNTAX") + finding("DS-PY-SYNTAX", filename, "", 1, max(1, row["lines"]), {"grammar": config["grammar"]}) + continue + row.update(status="ANALYZED", reason=None) + if row["lines"] > config["limits"]["file_lines"]: + finding("DS-PY-FILE-SIZE", filename, "", 1, row["lines"], {"lines": row["lines"], "limit": config["limits"]["file_lines"]}) + counts = Counter() + pending = [(tree, "")] + while pending: + node, prefix = pending.pop() + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + base = prefix + node.name; counts[base] += 1 + name = base + ("#" + str(counts[base]) if counts[base] > 1 else "") + if not isinstance(node, ast.ClassDef): + complexity = decisions(node); lines = node.end_lineno - node.lineno + 1 + row["functions"].append({"symbol": name, "line": node.lineno, "end_line": node.end_lineno, + "lines": lines, "complexity": complexity, "async": isinstance(node, ast.AsyncFunctionDef)}) + if complexity > config["limits"]["complexity"]: + finding("DS-PY-COMPLEXITY", filename, name, node.lineno, node.end_lineno, {"complexity": complexity, "limit": config["limits"]["complexity"]}) + if lines > config["limits"]["function_lines"]: + finding("DS-PY-FUNCTION-SIZE", filename, name, node.lineno, node.end_lineno, {"lines": lines, "limit": config["limits"]["function_lines"]}) + prefix = name + "." + if isinstance(node, ast.ExceptHandler) and node.type is None: + key = prefix + ""; counts[key] += 1 + finding("DS-PY-BARE-EXCEPT", filename, key + "#" + str(counts[key]), node.lineno, node.end_lineno, {"catches": "BaseException"}) + pending.extend((child, prefix) for child in reversed(list(ast.iter_child_nodes(node)))) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + targets = [a.name for a in node.names] + elif isinstance(node, ast.ImportFrom): + package = (row["module"] or "").split(".") + if not filename.endswith("/__init__.py"): package = package[:-1] + if node.level: + base = ".".join(package[:len(package) - node.level + 1]) if node.level <= len(package) else "" + base = ".".join(filter(None, (base, node.module))) + else: base = node.module or "" + targets = [base + "." + a.name if base + "." + a.name in module_paths else base for a in node.names] + if node.level and (row["module"] is None or node.level > len(package)): + targets = [""] + else: continue + for target in sorted(set(targets)): + candidates = sorted(module_paths.get(target, [])) + status = "EXTERNAL_OR_UNRESOLVED" if not candidates else "AMBIGUOUS" if len(candidates) > 1 else "RESOLVED" if entries[candidates[0]]["status"] == "ADMITTED" else "UNAVAILABLE" + imports.append({"from": filename, "to": candidates[0] if len(candidates) == 1 else None, "module": target, + "line": node.lineno, "status": status, "candidates": candidates}) + # A parsed source may import a present target whose syntax/encoding cannot be analyzed. + analyzed = {r["path"] for r in output if r["status"] == "ANALYZED"} + for edge in imports: + if edge["status"] == "RESOLVED" and edge["to"] not in analyzed: edge["status"] = "UNAVAILABLE" + imports.sort(key=lambda e: (e["from"], e["line"], e["module"])) + components = cycles(analyzed, imports) + for component in components: + finding("DS-PY-IMPORT-CYCLE", component[0], "cycle:" + digest(component)[7:], 1, 1, {"paths": component}, component) + findings.sort(key=lambda f: (f["path"], f["line"], f["rule"], f["symbol"])) + history = raw["history"]; changes = defaultdict(lambda: {"revisions": set(), "added": 0, "deleted": 0, "binary": 0}) + for commit in history["commits"]: + for change in commit["changes"]: + if change["path"] not in entries or entries[change["path"]]["status"] == "OUT_OF_SCOPE": continue + cell = changes[change["path"]]; cell["revisions"].add(commit["revision"]) + if change["added"] is None: cell["binary"] += 1 + else: cell["added"] += change["added"]; cell["deleted"] += change["deleted"] + hotspots = [{"path": p, "revisions": len(c["revisions"]), "added": c["added"], "deleted": c["deleted"], + "binary_changes": c["binary"], "coverage": history["status"]} for p, c in changes.items()] + hotspots.sort(key=lambda h: (-h["revisions"], -(h["added"] + h["deleted"]), h["path"])) + unknown = any(r["status"] in ("UNAVAILABLE", "PARSE_ERROR") for r in output) + applicable = any(e["status"] == "ADMITTED" or + (e["path"] and e["path"].endswith(".py") and e["status"] == "UNAVAILABLE") + for e in raw["entries"]) + return {"engine": ENGINE, "subject": deepcopy(raw["subject"]), "observed_at": raw["observed_at"], + "coverage": "PARTIAL" if unknown else "COMPLETE" if applicable else "NOT_APPLICABLE", "files": output, "findings": findings, + "imports": imports, "import_cycles": components, "hotspots": hotspots, + "history": {k: deepcopy(v) for k, v in history.items() if k != "commits"}, + "authority": "STATIC_SOURCE_EVIDENCE; no execution, independent acceptance, health score or merge authority"} diff --git a/src/devostasis/codeanalysis/git.py b/src/devostasis/codeanalysis/git.py new file mode 100644 index 0000000..7dc1576 --- /dev/null +++ b/src/devostasis/codeanalysis/git.py @@ -0,0 +1,206 @@ +"""Bounded local Git object reads. Never checkout, execute source or use a forge.""" + +import base64 +from datetime import datetime, timedelta, timezone +import hashlib +import os +from pathlib import Path +import re +import shutil +import subprocess +import threading +import time + +from ..canonical import digest +from ..workscope.model import instant, path, require, timestamp + + +def local_environment(): + allowed = {"PATH", "PATHEXT", "SYSTEMROOT", "WINDIR", "COMSPEC", "SYSTEMDRIVE", + "TEMP", "TMP", "TMPDIR", "HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", + "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "LANG", "LC_ALL", "LC_CTYPE"} + return {**{k: v for k, v in os.environ.items() if k.upper() in allowed}, + "GIT_NO_LAZY_FETCH": "1", "GIT_TERMINAL_PROMPT": "0", "GIT_OPTIONAL_LOCKS": "0"} + + +def object_digest(data, object_format, kind="blob"): + algorithm = hashlib.sha1 if object_format == "sha1" else hashlib.sha256 + return algorithm(kind.encode("ascii") + b" " + str(len(data)).encode("ascii") + b"\0" + data).hexdigest() + + +def blob_digest(data, object_format): + return object_digest(data, object_format) + + +class Git: + def __init__(self, directory, seconds=120): + self.directory = Path(directory).resolve() + require(self.directory.is_dir(), "local repository unavailable") + self.executable = shutil.which("git") + require(self.executable is not None, "Git executable unavailable") + self.deadline = time.monotonic() + seconds + self.calls = 0 + require(self.run(["rev-parse", "--is-bare-repository"]).strip() == b"false", "working repository required") + self.object_format = self.run(["rev-parse", "--show-object-format"]).strip().decode("ascii") + require(self.object_format in ("sha1", "sha256"), "unsupported Git object format") + + def run(self, args, *, data=None, cap=4 * 1024 * 1024): + remaining = self.deadline - time.monotonic() + require(remaining > 0, "GIT_TIME_CAP") + self.calls += 1 + command = [self.executable, "--no-lazy-fetch", "--no-replace-objects", "-c", "core.fsmonitor=false", "-c", "core.pager=cat", *args] + process = subprocess.Popen(command, cwd=self.directory, stdin=subprocess.PIPE if data is not None else subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + env=local_environment(), + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + chunks, problems = [], [] + def read(): + try: + value = process.stdout.read(cap + 1) + chunks.append(value) + if len(value) > cap: + process.kill() + except OSError: + problems.append("GIT_RESPONSE_UNAVAILABLE") + def write(): + try: + process.stdin.write(data); process.stdin.close() + except OSError: + problems.append("GIT_INPUT_UNAVAILABLE") + reader = threading.Thread(target=read, daemon=True); reader.start() + writer = threading.Thread(target=write, daemon=True) if data is not None else None + if writer: writer.start() + try: + process.wait(timeout=remaining) + except subprocess.TimeoutExpired as exc: + process.kill(); process.wait(); reader.join(timeout=1) + if writer: writer.join(timeout=1) + raise ValueError("GIT_TIME_CAP") from exc + reader.join(timeout=1) + if writer: writer.join(timeout=1) + process.stdout.close() + require(not reader.is_alive() and (not writer or not writer.is_alive()) and not problems, "GIT_RESPONSE_UNAVAILABLE") + result = b"".join(chunks) + require(len(result) <= cap, "GIT_RESPONSE_BYTE_CAP") + require(process.returncode == 0, "GIT_READ_FAILED") + return result + + def resolve(self, ref): + require(isinstance(ref, str) and 0 < len(ref) <= 1024 and not ref.startswith("-") and + not any(ord(c) < 32 for c in ref), "invalid Git revision") + commit = self.run(["rev-parse", "--verify", "--end-of-options", ref + "^{commit}"]).strip().decode("ascii") + require(re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", commit), "full Git commit required") + return commit + + +def collect(directory, ref, config, at=None): + git = Git(directory, config["budget"]["seconds"]) + commit = git.resolve(ref) + tree = git.run(["rev-parse", commit + "^{tree}"]).strip().decode("ascii") + commit_epoch = int(git.run(["show", "-s", "--format=%ct", commit]).strip()) + observed = timestamp(at or datetime.fromtimestamp(commit_epoch, timezone.utc).isoformat()) + # A local identity names the complete root set, never an inferred forge id. + roots_raw = git.run(["rev-list", "--max-parents=0", commit], cap=65536) + roots = sorted(set(roots_raw.decode("ascii").splitlines())) + require(0 < len(roots) <= 256 and all(re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", r) for r in roots), "local root identity unavailable") + rows = git.run(["ls-tree", "-r", "-l", "-z", "--full-tree", tree]) + commit_bytes = git.run(["cat-file", "commit", commit], cap=1024 * 1024) + tree_rows = git.run(["ls-tree", "-r", "-t", "-z", "--full-tree", tree]) + tree_ids = {tree} + for row in filter(None, tree_rows.split(b"\0")): + mode, kind, oid = row.split(b"\t", 1)[0].split() + if kind == b"tree": tree_ids.add(oid.decode("ascii")) + require(len(tree_ids) <= 10000, "TREE_PROOF_COUNT_CAP") + tree_proof = {} + if tree_ids: + raw = git.run(["cat-file", "--batch"], data="".join(t + "\n" for t in sorted(tree_ids)).encode("ascii"), cap=8 * 1024 * 1024) + offset = 0 + for oid in sorted(tree_ids): + end = raw.index(b"\n", offset); actual, kind, size = raw[offset:end].split(); offset = end + 1 + require(actual.decode("ascii") == oid and kind == b"tree", "tree proof header mismatch") + value = raw[offset:offset + int(size)]; offset += int(size) + require(raw[offset:offset + 1] == b"\n", "tree proof truncation"); offset += 1 + tree_proof[oid] = base64.b64encode(value).decode("ascii") + require(offset == len(raw), "unexpected tree proof bytes") + entries, selected, total = [], [], 0 + for row in sorted(filter(None, rows.split(b"\0")), key=lambda r: r.split(b"\t", 1)[1]): + header, filename = row.split(b"\t", 1) + mode, kind, oid, size = header.split() + entry = {"path": None, "path_base64": base64.b64encode(filename).decode("ascii"), "mode": mode.decode("ascii"), + "kind": kind.decode("ascii"), "object_id": oid.decode("ascii"), "size": None if size == b"-" else int(size), + "status": "UNAVAILABLE", "reason": "UNSAFE_PATH"} + try: + entry["path"] = path(filename.decode("utf-8")) + except (UnicodeError, ValueError): + entries.append(entry); continue + name = entry["path"] + included = not config["include"] or any(name == p or name.startswith(p + "/") for p in config["include"]) + excluded = any(name == p or name.startswith(p + "/") for p in config["exclude"]) + if not included or excluded: + entry.update(status="OUT_OF_SCOPE", reason="POLICY_EXCLUSION") + elif mode not in (b"100644", b"100755") or kind != b"blob": + entry.update(reason="SOURCE_LINK_OR_SUBMODULE") + elif not name.endswith(".py"): + entry.update(status="UNSUPPORTED", reason="LANGUAGE_UNSUPPORTED") + elif entry["size"] > config["budget"]["max_file_bytes"]: + entry.update(reason="FILE_BYTE_CAP") + elif len(selected) >= config["budget"]["max_files"]: + entry.update(reason="FILE_COUNT_CAP") + elif total + entry["size"] > config["budget"]["max_total_bytes"]: + entry.update(reason="TOTAL_BYTE_CAP") + else: + entry.update(status="ADMITTED", reason=None); selected.append(entry); total += entry["size"] + entries.append(entry) + sources = {} + if selected: + data = "".join(e["object_id"] + "\n" for e in selected).encode("ascii") + raw = git.run(["cat-file", "--batch"], data=data, cap=total + len(selected) * 160) + offset = 0 + for entry in selected: + end = raw.index(b"\n", offset) + oid, kind, size = raw[offset:end].split(); offset = end + 1 + require(oid.decode("ascii") == entry["object_id"] and kind == b"blob" and int(size) == entry["size"], "Git blob header mismatch") + value = raw[offset:offset + entry["size"]]; offset += entry["size"] + require(raw[offset:offset + 1] == b"\n" and blob_digest(value, git.object_format) == entry["object_id"], "Git blob content mismatch") + offset += 1; sources[entry["path"]] = base64.b64encode(value).decode("ascii") + require(offset == len(raw), "unexpected Git blob bytes") + start = instant(observed) - timedelta(days=config["history"]["days"]) + history = {"status": "NOT_REQUESTED", "reasons": [], "start": timestamp(start.isoformat()), "end": observed, + "commits": []} + if config["history"]["enabled"]: + limit = config["history"]["max_commits"] + try: + if git.run(["rev-parse", "--is-shallow-repository"]).strip() == b"true": + history["reasons"].append("SHALLOW_HISTORY") + raw = git.run(["log", "--no-merges", "--format=%H%x00%ct", "-z", "--since-as-filter=" + history["start"], + "--until=" + observed, "--max-count=" + str(limit + 1), commit], cap=(limit + 1) * 100) + values = [v for v in raw.split(b"\0") if v] + require(len(values) % 2 == 0, "Git history response malformed") + refs = [(values[i].decode("ascii"), int(values[i + 1])) for i in range(0, len(values), 2)] + history["status"] = "PARTIAL" if len(refs) > limit or history["reasons"] else "COMPLETE" + if len(refs) > limit: history["reasons"].append("HISTORY_COMMIT_CAP") + for oid, epoch in refs[:limit]: + when = datetime.fromtimestamp(epoch, timezone.utc) + require(start <= when <= instant(observed), "Git history timestamp outside window") + raw = git.run(["diff-tree", "--root", "--no-commit-id", "--numstat", "-z", "-r", "--no-renames", + "--no-ext-diff", "--no-textconv", oid]) + changes = [] + for change in filter(None, raw.split(b"\0")): + added, deleted, filename = change.split(b"\t", 2) + try: name = path(filename.decode("utf-8")) + except (ValueError, UnicodeError): + history["status"] = "PARTIAL" + if "HISTORY_PATH_UNAVAILABLE" not in history["reasons"]: + history["reasons"].append("HISTORY_PATH_UNAVAILABLE") + continue + changes.append({"path": name, "added": None if added == b"-" else int(added), + "deleted": None if deleted == b"-" else int(deleted)}) + history["commits"].append({"revision": oid, "committed_at": timestamp(when.isoformat()), "changes": sorted(changes, key=lambda c: c["path"])}) + except (ValueError, OSError) as exc: + history["status"] = "PARTIAL" if history["commits"] else "UNAVAILABLE" + history["reasons"].append("HISTORY_TIME_CAP" if "TIME_CAP" in str(exc) else "HISTORY_READ_UNAVAILABLE") + history["commits"].sort(key=lambda c: (c["committed_at"], c["revision"])) + return {"subject": {"kind": "LOCAL_GIT", "object_format": git.object_format, "roots": roots, + "repository_id": digest({"object_format": git.object_format, "roots": roots}), "revision": commit, "tree": tree}, + "observed_at": observed, "entries": entries, "sources": sources, "history": history, + "proof": {"commit": base64.b64encode(commit_bytes).decode("ascii"), "trees": tree_proof}} diff --git a/src/devostasis/workscope/collect.py b/src/devostasis/workscope/collect.py index c43fae4..12650a2 100644 --- a/src/devostasis/workscope/collect.py +++ b/src/devostasis/workscope/collect.py @@ -179,6 +179,13 @@ def obj(self, path): def pages(self, path, params=None, key=None): items, seen = [], set() + declared_total, declared_pages = None, None + def integer_header(headers, name): + value = headers.get(name) + if value is None: + return None + require(isinstance(value, str) and 0 < len(value) <= 20 and value.isascii() and value.isdecimal(), "INVALID_PAGINATION_HEADER") + return int(value) for page in range(1, self.max_pages + 1): try: body, headers = self.get(path, {**(params or {}), "per_page": 100, "page": page}) @@ -192,6 +199,35 @@ def pages(self, path, params=None, key=None): require(marker not in seen, "PAGINATION_IDENTITY_SHIFT") seen.add(marker); items.append(entry) next_page = headers.get("x-next-page") if self.provider == "gitlab" else 'rel="next"' in headers.get("link", "") + if self.provider == "gitlab": + require("x-next-page" not in headers or isinstance(next_page, str), "INVALID_PAGINATION_HEADER") + current = integer_header(headers, "x-page") + per_page = integer_header(headers, "x-per-page") + total = integer_header(headers, "x-total") + pages = integer_header(headers, "x-total-pages") + require(current is None or current == page, "PAGINATION_PAGE_MISMATCH") + require(per_page is None or per_page == 100, "PAGINATION_PAGE_MISMATCH") + require(total is None or declared_total is None or total == declared_total, "PAGINATION_TOTAL_SHIFT") + require(pages is None or declared_pages is None or pages == declared_pages, "PAGINATION_TOTAL_SHIFT") + if total is not None: declared_total = total + if pages is not None: declared_pages = pages + require(declared_total is None or len(items) <= declared_total, "PAGINATION_TOTAL_MISMATCH") + require(declared_pages is None or page <= max(1, declared_pages), "PAGINATION_PAGE_MISMATCH") + require(declared_total is None or declared_pages is None or declared_pages == (declared_total + 99) // 100, "PAGINATION_TOTAL_MISMATCH") + if next_page: + require(integer_header(headers, "x-next-page") == page + 1 and len(entries) == 100, "PAGINATION_PAGE_MISMATCH") + require(declared_total is None or len(items) < declared_total, "PAGINATION_TOTAL_MISMATCH") + require(declared_pages is None or page < declared_pages, "PAGINATION_PAGE_MISMATCH") + terminal = not next_page and (len(entries) < 100 or "x-next-page" in headers) + if terminal: + require(declared_total is None or len(items) == declared_total, "PAGINATION_TOTAL_MISMATCH") + require(declared_pages is None or page == max(1, declared_pages), "PAGINATION_PAGE_MISMATCH") + elif key and "total_count" in body: + total = body["total_count"] + require(type(total) is int and total >= 0, "INVALID_LIST_RESPONSE") + require(declared_total is None or total == declared_total, "PAGINATION_TOTAL_SHIFT") + declared_total = total + require(len(items) <= total, "PAGINATION_TOTAL_MISMATCH") # Never follow server-supplied URLs with credentials. Request our own page. if not next_page and len(entries) < 100: if key and body.get("total_count", len(items)) > len(items): diff --git a/tests/test_code_analysis.py b/tests/test_code_analysis.py new file mode 100644 index 0000000..a9e0bb2 --- /dev/null +++ b/tests/test_code_analysis.py @@ -0,0 +1,378 @@ +"""DEV-CODE: real offline Git evidence, conservative deltas and work integration.""" + +import base64 +from copy import deepcopy +import json +import os +from pathlib import Path +import subprocess + +import pytest + +from devostasis.canonical import canonical_bytes, digest +from devostasis.cli import main +from devostasis.codeanalysis.bundle import admit, build, publish, read, verify, verify_content +from devostasis.codeanalysis.consumer import compare, packet, verify_packet, work_evidence +from devostasis.codeanalysis.engine import default_policy, policy +from devostasis.codeanalysis.git import Git, collect + +AT = "2026-10-08T12:00:00Z" +NEXT = "2026-10-09T12:00:00Z" + + +def git(repo, *args, data=None, at=AT): + return subprocess.run(["git", "-c", "commit.gpgSign=false", *args], cwd=repo, input=data, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, + env={**{k: v for k, v in os.environ.items() if k.upper() in {"PATH", "SYSTEMROOT", "TEMP", "TMP", "HOME", "USERPROFILE"}}, + "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_AUTHOR_NAME": "Synthetic Fixture", "GIT_COMMITTER_NAME": "Synthetic Fixture", + "GIT_AUTHOR_EMAIL": "fixture@example.invalid", "GIT_COMMITTER_EMAIL": "fixture@example.invalid", + "GIT_AUTHOR_DATE": at, "GIT_COMMITTER_DATE": at}).stdout + + +def commit(repo, files, at=AT): + for name, value in files.items(): + destination = repo / name + if value is None: + destination.unlink(); continue + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(value.encode("utf-8") if isinstance(value, str) else value) + git(repo, "add", "--all"); git(repo, "commit", "-m", "synthetic source", at=at) + return git(repo, "rev-parse", "HEAD").strip().decode() + + +def repository(tmp_path, files=None): + repo = tmp_path / "repository"; repo.mkdir() + git(repo, "init", "--initial-branch=main", "--object-format=sha1") + commit(repo, files or {"src/pkg/__init__.py": "", "src/pkg/a.py": "from . import b\n\ndef choose(x):\n if x:\n return 1\n return 0\n", + "src/pkg/b.py": "from . import a\n\ndef fallback():\n try:\n return 1\n except:\n return 0\n", "README.md": "fixture"}) + return repo + + +def analyze(repo, config=None, at=AT, ref="HEAD"): + config = deepcopy(config or default_policy()) + config["limits"]["complexity"] = 1 + raw = collect(repo, ref, config, at) + manifest, members = build(raw, config) + return raw, config, manifest, members, verify_content(members) + + +def test_code_real_git_to_byte_identical_offline_bundle_without_source_execution(tmp_path): + repo = repository(tmp_path) + marker = tmp_path / "executed" + commit(repo, {"danger.py": f"from pathlib import Path\nPath({str(marker)!r}).write_text('executed')\n"}, NEXT) + a = analyze(repo, at=NEXT); b = analyze(repo, at=NEXT) + assert a[2:] == b[2:] and not marker.exists() + root = publish(tmp_path / "analysis", a[3]); publish(root, a[3]) + git(repo, "checkout", "--detach", "HEAD~1") + assert verify(root)["manifest.json"] == a[2] and not marker.exists() + assert a[4]["analysis.json"]["import_cycles"] == [["src/pkg/a.py", "src/pkg/b.py"]] + assert {f["rule"] for f in a[4]["analysis.json"]["findings"]} >= {"DS-PY-COMPLEXITY", "DS-PY-BARE-EXCEPT", "DS-PY-IMPORT-CYCLE"} + + +@pytest.mark.parametrize("mutation", ["blob", "commit", "tree", "inventory_path", "inventory_oid", "foreign_source", "missing_tree", "unknown_field", "finding", "report", "policy_version", "engine"]) +def test_code_rehashed_or_changed_bundle_cannot_bypass_source_proof_and_replay(tmp_path, mutation): + _, _, _, members, decoded = analyze(repository(tmp_path)) + changed = dict(members) + if mutation in ("finding", "report", "engine"): + if mutation == "report": changed["report.md"] += b"false verdict" + elif mutation == "engine": + manifest = deepcopy(decoded["manifest.json"]); manifest["engine"] = "unrecognized" + changed["manifest.json"] = canonical_bytes(manifest) + else: + result = deepcopy(decoded["analysis.json"]); result["findings"] = [] + changed["analysis.json"] = canonical_bytes(result) + else: + raw = deepcopy(decoded["inputs.json"]) + if mutation == "blob": raw["sources"]["src/pkg/a.py"] = base64.b64encode(b"forged bytes").decode() + if mutation == "commit": raw["proof"]["commit"] = base64.b64encode(b"tree " + raw["subject"]["tree"].encode() + b"\n\nforged").decode() + if mutation == "tree": raw["proof"]["trees"][raw["subject"]["tree"]] = "" + if mutation == "inventory_path": raw["entries"][0]["path"] = "elsewhere.py" + if mutation == "inventory_oid": raw["entries"][0]["object_id"] = "0" * 40 + if mutation == "foreign_source": raw["sources"]["outside.py"] = "" + if mutation == "missing_tree": raw["proof"]["trees"].pop(raw["subject"]["tree"]) + if mutation == "unknown_field": raw["ignored"] = True + changed["inputs.json"] = canonical_bytes(raw) + if mutation == "policy_version": + config = deepcopy(decoded["policy.json"]); config["grammar"] = "latest" + changed["policy.json"] = canonical_bytes(config) + # Updating member digests and the outer id is insufficient to admit a lie. + manifest = json.loads(changed["manifest.json"]) + manifest["members"] = {k: digest(json.loads(v)) if k != "report.md" else "sha256:" + __import__("hashlib").sha256(v).hexdigest() + for k, v in changed.items() if k != "manifest.json"} + manifest["bundle_id"] = digest({k: v for k, v in manifest.items() if k != "bundle_id"}) + changed["manifest.json"] = canonical_bytes(manifest) + with pytest.raises((ValueError, KeyError)): verify_content(changed) + + +@pytest.mark.parametrize("budget,value,reason", [("max_files", 1, "FILE_COUNT_CAP"), ("max_file_bytes", 20, "FILE_BYTE_CAP"), ("max_total_bytes", 100, "TOTAL_BYTE_CAP")]) +def test_code_read_caps_are_explicit_and_never_become_empty_success(tmp_path, budget, value, reason): + repo = repository(tmp_path); config = default_policy(); config["budget"][budget] = value + if budget == "max_total_bytes": config["budget"]["max_file_bytes"] = value + raw, _, _, _, decoded = analyze(repo, config) + assert decoded["analysis.json"]["coverage"] == "PARTIAL" + assert reason in {e["reason"] for e in raw["entries"]} + assert all(e["path"] not in raw["sources"] for e in raw["entries"] if e["status"] != "ADMITTED") + + +def test_code_syntax_encoding_unsupported_and_policy_exclusion_have_distinct_states(tmp_path): + repo = repository(tmp_path, {"broken.py": "def invalid(:\n", "encoding.py": b"# coding: utf-8\n\xff", "script.js": "throw 1;", + "vendor/a.py": "if x:\n pass\n", "valid.py": "def fine():\n return 1\n"}) + decoded = analyze(repo)[4]; files = {r["path"]: r for r in decoded["analysis.json"]["files"]} + assert files["broken.py"]["status"] == "PARSE_ERROR" + assert files["encoding.py"]["status"] == "UNAVAILABLE" and files["encoding.py"]["functions"] == [] + assert files["script.js"]["status"] == "UNSUPPORTED" and files["script.js"]["lines"] is None + assert files["vendor/a.py"]["status"] == "OUT_OF_SCOPE" + assert files["valid.py"]["status"] == "ANALYZED" + assert any(f["rule"] == "DS-PY-SYNTAX" for f in decoded["analysis.json"]["findings"]) + + +def test_code_non_utf8_python_cookie_is_analyzed_without_rewriting_blob_bytes(tmp_path): + data = b"# coding: latin-1\nname = '\xe9'\n" + decoded = analyze(repository(tmp_path, {"latin.py": data}))[4] + assert decoded["analysis.json"]["files"][0]["status"] == "ANALYZED" + assert base64.b64decode(decoded["inputs.json"]["sources"]["latin.py"]) == data + + +def test_code_git_link_and_submodule_are_not_followed(tmp_path): + repo = repository(tmp_path, {"safe.py": "pass\n"}) + blob = git(repo, "hash-object", "-w", "--stdin", data=b"../private.py").strip().decode() + head = git(repo, "rev-parse", "HEAD").strip().decode() + git(repo, "update-index", "--add", "--cacheinfo", f"120000,{blob},linked.py") + git(repo, "update-index", "--add", "--cacheinfo", f"160000,{head},submodule") + git(repo, "commit", "-m", "synthetic linked objects", at=NEXT) + raw = analyze(repo, at=NEXT)[0] + assert {e["path"] for e in raw["entries"] if e["reason"] == "SOURCE_LINK_OR_SUBMODULE"} == {"linked.py", "submodule"} + assert set(raw["sources"]) == {"safe.py"} + + +def test_code_import_ambiguity_and_missing_target_never_form_proven_cycles(tmp_path): + repo = repository(tmp_path, {"a.py": "import b\nimport external\n", "b.py": "import a\n", "src/b.py": "import a\n"}) + result = analyze(repo)[4]["analysis.json"] + assert any(e["status"] == "AMBIGUOUS" and e["module"] == "b" for e in result["imports"]) + assert any(e["status"] == "EXTERNAL_OR_UNRESOLVED" and e["module"] == "external" for e in result["imports"]) + assert result["import_cycles"] == [] + + +def test_code_nested_function_decisions_are_not_double_counted_and_finding_ids_survive_line_moves(tmp_path): + code = "def outer(x):\n def inner(y):\n if y and x:\n return y\n return 0\n return inner(x)\n" + repo = repository(tmp_path, {"a.py": code}) + before = analyze(repo)[4] + functions = {f["symbol"]: f for f in before["analysis.json"]["files"][0]["functions"]} + assert functions["outer"]["complexity"] == 1 and functions["outer.inner"]["complexity"] == 3 + commit(repo, {"a.py": "\n\n" + code}, NEXT) + after = analyze(repo, at=NEXT)[4] + assert before["analysis.json"]["findings"][0]["id"] == after["analysis.json"]["findings"][0]["id"] + assert compare(before, after)["findings"][0]["state"] == "CHANGED" + + +def test_code_partial_history_counts_are_labeled_and_time_window_excludes_future_commits(tmp_path): + repo = repository(tmp_path, {"a.py": "x = 1\n"}) + commit(repo, {"a.py": "x = 2\n"}, NEXT) + commit(repo, {"a.py": "x = 3\n"}, "2026-10-10T12:00:00Z") + config = default_policy(); config["history"]["max_commits"] = 1 + result = analyze(repo, config, at=NEXT)[4]["analysis.json"] + assert result["history"]["status"] == "PARTIAL" and result["hotspots"][0]["coverage"] == "PARTIAL" + assert result["hotspots"][0]["revisions"] == 1 + + +def test_code_missing_source_cannot_resolve_a_finding_and_deleted_source_can(tmp_path): + repo = repository(tmp_path, {"b.py": "def choose(x):\n if x:\n return 1\n return 0\n"}) + config = default_policy(); config["budget"]["max_files"] = 1 + before = analyze(repo, config)[4] + commit(repo, {"a.py": "pass\n"}, NEXT) + after = analyze(repo, config, at=NEXT)[4] + assert compare(before, after)["findings"][0]["state"] == "UNOBSERVED" + commit(repo, {"b.py": None}, "2026-10-10T12:00:00Z") + deleted = analyze(repo, config, at="2026-10-10T12:00:00Z")[4] + assert compare(before, deleted)["findings"][0]["state"] == "RESOLVED" + + +def test_code_changed_policy_or_unrelated_root_identity_is_incomparable(tmp_path): + repo = repository(tmp_path); before = analyze(repo)[4] + config = default_policy(); config["version"] = "other" + assert compare(before, analyze(repo, config)[4])["reasons"] == ["ANALYSIS_POLICY_CHANGED"] + other = tmp_path / "other"; other.mkdir() + unrelated = repository(other, {"unique.py": "pass\n"}) + assert compare(before, analyze(unrelated)[4])["reasons"] == ["LOCAL_REPOSITORY_IDENTITY_CHANGED"] + + +@pytest.mark.parametrize("mutation", ["bytes", "path", "subject", "budget", "authority", "duplicates"]) +def test_code_offline_source_packets_reject_tampering_and_enforce_declared_limits(tmp_path, mutation): + decoded = analyze(repository(tmp_path))[4] + value = packet(decoded, ["src/pkg/a.py"]) + assert verify_packet(value, decoded) == value + with pytest.raises(ValueError): packet(decoded, ["src/pkg/a.py"], max_bytes=1) + if mutation == "bytes": value["files"][0]["base64"] = base64.b64encode(b"forged").decode() + if mutation == "path": value["files"][0]["path"] = "../escape" + if mutation == "subject": value["subject"]["revision"] = "0" * 40 + if mutation == "budget": value["budget"]["max_bytes"] = 1 + if mutation == "authority": value["authority"] = "permission to merge" + if mutation == "duplicates": value["files"].append(deepcopy(value["files"][0])) + value["packet_id"] = digest({k: v for k, v in value.items() if k != "packet_id"}) + with pytest.raises(ValueError): verify_packet(value, decoded) + + +def test_code_cli_offline_end_to_end_and_admitted_work_evidence(tmp_path, capsys): + from test_work_handoff import fixture + repo = repository(tmp_path); output = tmp_path / "analysis" + assert main(["code", "analyze", "--repo", str(repo), "--output", str(output), "--at", AT]) == 0 + result = json.loads(capsys.readouterr().out) + assert main(["code", "verify", "--bundle", str(output)]) == 0; capsys.readouterr() + assert main(["code", "sarif", "--bundle", str(output), "--output", str(tmp_path / "report.sarif")]) == 0 + assert main(["code", "packet", "--bundle", str(output), "--path", "src/pkg/a.py", "--output", str(tmp_path / "packet.json")]) == 0 + assert main(["code", "verify-packet", "--bundle", str(output), "--packet", str(tmp_path / "packet.json")]) == 0; capsys.readouterr() + assert main(["code", "compare", "--before", str(output), "--after", str(output)]) == 0 + assert json.loads(capsys.readouterr().out)["status"] == "COMPARABLE" + inv = deepcopy(fixture()[0]["inventory.json"]) + inv["subject"]["revision"] = verify(output)["analysis.json"]["subject"]["revision"] + inv["subject"]["source_binding"]["revision"] = inv["subject"]["revision"] + data = work_evidence(verify(output), inv) + assert data["sources"][0]["subject"] == inv["subject"] and data["sources"][0]["profile"] == "sarif.v1" + inv["subject"]["revision"] = "0" * 40; inv["subject"]["source_binding"]["revision"] = "0" * 40 + with pytest.raises(ValueError, match="revision mismatch"): work_evidence(verify(output), inv) + assert result["bundle_id"] == verify(output)["manifest.json"]["bundle_id"] + + +def test_code_repository_strings_are_escaped_in_the_report_and_foreign_outputs_are_preserved(tmp_path): + name = "![badge](x).py" + repo = repository(tmp_path, {name: "def choose(x):\n if x:\n return 1\n return 0\n"}) + members = analyze(repo)[3] + assert b"![badge](x)" not in members["report.md"] + foreign = tmp_path / "foreign"; foreign.mkdir(); (foreign / "keep.txt").write_text("keep") + with pytest.raises(ValueError): publish(foreign, members) + assert (foreign / "keep.txt").read_text() == "keep" + + +@pytest.mark.parametrize("field,value", [("grammar", "latest"), ("unknown", True), ("include", ["../outside"]), ("source_roots", []), ("history", {"enabled": 1, "days": 28, "max_commits": 500})]) +def test_code_policy_rejects_unknown_or_unbounded_inputs(field, value): + config = default_policy(); config[field] = value + with pytest.raises(ValueError): policy(config) + + +def test_code_no_python_scope_is_not_applicable_and_syntax_only_scope_is_partial(tmp_path): + repo = repository(tmp_path, {"script.js": "throw 1;"}) + assert analyze(repo)[4]["analysis.json"]["coverage"] == "NOT_APPLICABLE" + commit(repo, {"broken.py": "def invalid(:\n"}, NEXT) + assert analyze(repo, at=NEXT)[4]["analysis.json"]["coverage"] == "PARTIAL" + + +def test_code_shallow_history_retains_explicit_partial_coverage(tmp_path): + repo = repository(tmp_path); commit(repo, {"a.py": "pass\n"}, NEXT) + clone = tmp_path / "shallow" + git(tmp_path, "clone", "--depth=1", repo.as_uri(), str(clone)) + result = analyze(clone, at=NEXT)[4]["analysis.json"] + assert result["history"]["status"] == "PARTIAL" + assert "SHALLOW_HISTORY" in result["history"]["reasons"] + + +def test_code_sha256_repository_proofs_replay_with_explicit_object_format(tmp_path): + repo = tmp_path / "sha256"; repo.mkdir() + git(repo, "init", "--initial-branch=main", "--object-format=sha256") + commit(repo, {"a.py": "def simple():\n return 1\n"}) + decoded = analyze(repo)[4] + assert decoded["analysis.json"]["subject"]["object_format"] == "sha256" + assert len(decoded["analysis.json"]["subject"]["revision"]) == 64 + + +def test_code_relative_import_outside_declared_package_is_unresolved(tmp_path): + repo = repository(tmp_path, {"src/pkg/a.py": "from ... import b\n", "src/b.py": "import pkg.a\n"}) + result = analyze(repo)[4]["analysis.json"] + assert result["import_cycles"] == [] + assert result["imports"][1]["status"] == "EXTERNAL_OR_UNRESOLVED" + + +def test_code_git_caps_and_option_like_revisions_fail_explicitly(tmp_path): + repo = repository(tmp_path); reader = Git(repo) + with pytest.raises(ValueError, match="BYTE_CAP"): + reader.run(["cat-file", "commit", "HEAD"], cap=1) + with pytest.raises(ValueError, match="invalid Git revision"): + reader.resolve("--all") + reader.deadline = 0 + with pytest.raises(ValueError, match="TIME_CAP"): + reader.run(["rev-parse", "HEAD"]) + + +def test_code_work_export_preserves_gaps_and_age_and_produces_analysis_queue(tmp_path): + from test_work_handoff import fixture + from devostasis.workscope.bundle import build as work_build, verify_content as work_verify + from devostasis.workscope.collect import complete + from devostasis.workscope.model import default_policy as work_policy + repo = repository(tmp_path, {"a.py": "def choose(x):\n if x:\n return 1\n return 0\n", "broken.py": "def invalid(:\n"}) + decoded = analyze(repo)[4] + inv = deepcopy(fixture()[0]["inventory.json"]) + inv["subject"]["revision"] = decoded["analysis.json"]["subject"]["revision"] + inv["subject"]["source_binding"]["revision"] = inv["subject"]["revision"] + inv.update(observed_at=AT, changes=complete(), issues=complete(), receipts=[], selection={"changes": "OPEN_AND_REGISTERED", "issues": []}) + evidence = work_evidence(decoded, inv) + entry = evidence["sources"][0] + assert entry["status"] == "PARTIAL" and entry["observed_at"] == AT + with pytest.raises(ValueError, match="observation time"): + work_evidence(decoded, inv, NEXT) + _, content = work_build(inv, work_policy("worker"), evidence) + result = work_verify(content)["scope.json"] + assert any(i["queue"] == "analyze_code" for i in result["items"]) + assert any("CODE_SOURCE_COVERAGE_PARTIAL" in gap["reason"] for gap in result["gaps"]) + inv["observed_at"] = "2026-10-20T12:00:00Z" + _, stale = work_build(inv, work_policy("worker"), evidence) + stale_scope = work_verify(stale)["scope.json"] + assert all(item["action"] == "recover_evidence" for item in stale_scope["items"]) + assert any(gap["reason"] == "REPORT_EXPIRED_OR_FUTURE" for gap in stale_scope["gaps"]) + + +def test_code_frozen_native_and_work_examples_replay_with_admitted_source_packet(): + from devostasis.workscope.bundle import verify as verify_work + root = Path(__file__).resolve().parents[1] / "examples/code" + decoded = verify(root / "bundle") + verify_packet(json.loads((root / "packet.json").read_text()), decoded) + work = verify_work(root / "work-bundle") + assert any(i["queue"] == "analyze_code" for i in work["scope.json"]["items"]) + for name, value in (("code-manifest", decoded["manifest.json"]), + ("code-policy", decoded["policy.json"]), + ("code-packet", json.loads((root / "packet.json").read_text()))): + schema = json.loads((root.parents[1] / "schemas" / (name + ".schema.json")).read_text()) + assert schema["additionalProperties"] is False + assert set(value) == set(schema["required"]) == set(schema["properties"]) + + +def test_code_sarif_uri_encoding_and_legacy_work_paths_are_bound_to_real_filenames(tmp_path): + from devostasis.codeanalysis.bundle import sarif + from test_work_handoff import fixture + name = "space # name.py" + decoded = analyze(repository(tmp_path, {name: "def choose(x):\n if x:\n return 1\n return 0\n"}))[4] + report = sarif(decoded["analysis.json"]) + assert report["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"] == "space%20%23%20name.py" + inv = deepcopy(fixture()[0]["inventory.json"]) + inv["subject"]["revision"] = decoded["analysis.json"]["subject"]["revision"] + inv["subject"]["source_binding"]["revision"] = inv["subject"]["revision"] + evidence = work_evidence(decoded, inv) + exported = json.loads(base64.b64decode(evidence["sources"][0]["report"]["base64"])) + assert exported["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"] == name + + +def test_code_function_counts_exclude_definition_time_decisions_and_missing_root_packages(tmp_path): + repo = repository(tmp_path, {"src/__init__.py": "from . import app\n", + "src/app.py": "@(left if condition else right)\ndef choose(value=1 if condition else 0):\n return value\n"}) + result = analyze(repo)[4]["analysis.json"] + row = next(f for f in result["files"] if f["path"] == "src/app.py") + assert row["functions"][0]["complexity"] == 1 + assert result["imports"][0]["status"] == "EXTERNAL_OR_UNRESOLVED" + + +def test_code_git_inherited_repository_and_secret_environment_cannot_override_selected_source(tmp_path, monkeypatch): + repo = repository(tmp_path) + other_root = tmp_path / "other"; other_root.mkdir() + other = repository(other_root, {"foreign.py": "pass\n"}) + expected = git(repo, "rev-parse", "HEAD").strip().decode() + monkeypatch.setenv("GIT_DIR", str(other / ".git")) + monkeypatch.setenv("GIT_WORK_TREE", str(other)) + monkeypatch.setenv("PRIVATE_CREDENTIAL_SENTINEL", "synthetic-never-forward") + original = subprocess.Popen + environments = [] + def capture(*args, **kwargs): + environments.append(kwargs["env"]) + return original(*args, **kwargs) + monkeypatch.setattr(subprocess, "Popen", capture) + result = analyze(repo)[4]["analysis.json"] + assert result["subject"]["revision"] == expected + assert all(not ({"GIT_DIR", "GIT_WORK_TREE", "PRIVATE_CREDENTIAL_SENTINEL"} & set(env)) for env in environments) diff --git a/tests/test_work_pagination.py b/tests/test_work_pagination.py new file mode 100644 index 0000000..207aa6c --- /dev/null +++ b/tests/test_work_pagination.py @@ -0,0 +1,54 @@ +"""DEV-PAGES: contradictory provider metadata cannot claim complete selection.""" + +import pytest + +from devostasis.workscope.collect import Client + + +def page(items, **headers): + return [{"id": i} for i in items], {k.replace("_", "-"): str(v) for k, v in headers.items()} + + +def collect(pages, provider="gitlab", key=None): + client = Client(provider, "https://example.invalid/api", max_pages=3) + client.get = lambda path, params: pages[params["page"] - 1] + return client.pages("/items", key=key) + + +@pytest.mark.parametrize("headers,reason", [ + ({"x_total": 101, "x_next_page": ""}, "PAGINATION_TOTAL_MISMATCH"), + ({"x_total": 99, "x_next_page": ""}, "PAGINATION_TOTAL_MISMATCH"), + ({"x_page": 2, "x_next_page": ""}, "PAGINATION_PAGE_MISMATCH"), + ({"x_per_page": 50, "x_next_page": ""}, "PAGINATION_PAGE_MISMATCH"), + ({"x_next_page": 3}, "PAGINATION_PAGE_MISMATCH"), + ({"x_next_page": "https://foreign.invalid/?token=x"}, "INVALID_PAGINATION_HEADER"), + ({"x_total": "-1", "x_next_page": ""}, "INVALID_PAGINATION_HEADER"), + ({"x_total": "many", "x_next_page": ""}, "INVALID_PAGINATION_HEADER"), + ({"x_total": 100, "x_total_pages": 2, "x_next_page": ""}, "PAGINATION_TOTAL_MISMATCH"), + ({"x_total": 100, "x_next_page": 2}, "PAGINATION_TOTAL_MISMATCH"), +]) +def test_work_pagination_contradictory_headers_preserve_observed_partial_items(headers, reason): + value = collect([page(range(100), **headers)]) + assert value["status"] == "PARTIAL" and len(value["items"]) == 100 + assert value["reasons"] == [reason] + + +def test_work_pagination_changed_total_between_pages_is_partial(): + value = collect([page(range(100), x_total=101, x_next_page=2), page([100], x_total=102, x_next_page="")]) + assert value["status"] == "PARTIAL" and value["reasons"] == ["PAGINATION_TOTAL_SHIFT"] + + +@pytest.mark.parametrize("pages", [ + [page(range(100), x_page=1, x_total=100, x_total_pages=1, x_next_page="")], + [page(range(100)), page([])], + [page(range(100), x_total=101, x_next_page=2), page([100], x_total=101, x_next_page="")], + [page([], x_total=0, x_total_pages=0, x_next_page="")], +]) +def test_work_pagination_valid_exact_full_empty_and_header_absent_responses_are_complete(pages): + assert collect(pages)["status"] == "COMPLETE" + + +@pytest.mark.parametrize("total", [1001, True, -1, "1"]) +def test_work_pagination_github_result_caps_and_invalid_totals_are_not_complete(total): + result = collect([({"items": [{"id": 1}], "total_count": total}, {})], "github", "items") + assert result["status"] == "PARTIAL" From f493099e84d3ef25c45a486da620f3dac06aa3cb Mon Sep 17 00:00:00 2001 From: Dreven Date: Sun, 18 Oct 2026 23:43:31 +0200 Subject: [PATCH 22/33] fix: retarget Dependabot to existing 0.6 release branch (#63) --- .github/dependabot.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 03ced88..b4eed35 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,7 +3,7 @@ updates: - package-ecosystem: "github-actions" directory: "/" # Feature and maintenance PRs target the active release, per CONTRIBUTING.md. - target-branch: "release/0.3.0" + target-branch: "release/0.6.0" schedule: interval: "weekly" day: "tuesday" @@ -15,7 +15,7 @@ updates: default-days: 7 - package-ecosystem: "pip" directory: "/" - target-branch: "release/0.3.0" + target-branch: "release/0.6.0" schedule: interval: "weekly" day: "tuesday" From e4ffe138e5cf159f77e7e82c2c57740bc7cf47d1 Mon Sep 17 00:00:00 2001 From: Unisphere Dev Date: Wed, 21 Oct 2026 14:16:18 +0300 Subject: [PATCH 23/33] 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 88a4650..50aa822 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 fdd594aa2f9e6064865891b32b48794493f90f8f Mon Sep 17 00:00:00 2001 From: Unisphere Dev Date: Wed, 21 Oct 2026 14:16:20 +0300 Subject: [PATCH 24/33] Validate source omissions and expose unproven size claims --- docs/code-analysis.md | 6 +++ docs/spec/code-analysis.md | 12 ++++++ src/devostasis/codeanalysis/bundle.py | 12 +++++- src/devostasis/codeanalysis/cli.py | 5 ++- src/devostasis/codeanalysis/git.py | 42 ++++++++++-------- tests/test_source_admission.py | 61 +++++++++++++++++++++++++++ 6 files changed, 118 insertions(+), 20 deletions(-) create mode 100644 tests/test_source_admission.py diff --git a/docs/code-analysis.md b/docs/code-analysis.md index ba700b8..ca29021 100644 --- a/docs/code-analysis.md +++ b/docs/code-analysis.md @@ -48,6 +48,12 @@ The local identity derives from the recorded root commit IDs, not a forge URL; shallow or unrelated roots and changed policy make comparisons INCOMPARABLE. History counts and root traversal are acquisition observations; their complete ancestry is not independently proved by the source tree proof (issue #73). +Likewise, a tree binds an omitted blob's identity, not its separately recorded +size. Verification checks the deterministic selection predicates and marks +omitted blob sizes `UNPROVEN` in its operational `proof_scope` receipt. Byte-cap +reasons that depend on those sizes remain acquisition claims; `VERIFIED` means +the admitted proofs and recorded bundle replay, not proof of those omissions. +The receipt is outside the immutable artifact and does not rewrite v1 bundles. Compare two verified bundles under the same policy: diff --git a/docs/spec/code-analysis.md b/docs/spec/code-analysis.md index 433a6b0..a10d93d 100644 --- a/docs/spec/code-analysis.md +++ b/docs/spec/code-analysis.md @@ -45,6 +45,18 @@ UNSUPPORTED/LANGUAGE_UNSUPPORTED. UNAVAILABLE reasons are UNSAFE_PATH, SOURCE_LINK_OR_SUBMODULE, FILE_BYTE_CAP, FILE_COUNT_CAP and TOTAL_BYTE_CAP. `sources` maps exactly admitted paths to canonical base64 of unmodified bytes. +Offline admission applies the collector's same ordered predicates: safe path, +policy scope, regular blob mode, supported language, per-file bytes, preceding +admitted-file count and preceding verified bytes plus current declared size. +A recognized reason alone is insufficient to omit an otherwise eligible file. +Path/mode and policy/count predicates are independently checked. A tree does +not authenticate a blob's size when its bytes are omitted; byte-based omission +predicates can only be replayed against that acquisition claim. They do not +prove that the omission was necessary. `code verify` reports `proof_scope` +outside the canonical members: source tree and admitted blobs VERIFIED, +omitted blob sizes UNPROVEN (or NOT_APPLICABLE), history ancestry UNPROVEN. +No unbounded blob read or new compatibility/migration rule is implied. + `proof` contains the raw base64 commit object and an object-ID-to-base64 map of every reachable tree. Verification computes framed Git object hashes, checks the commit/tree link, expands all trees, checks the entire inventory and diff --git a/src/devostasis/codeanalysis/bundle.py b/src/devostasis/codeanalysis/bundle.py index ca84c37..a291775 100644 --- a/src/devostasis/codeanalysis/bundle.py +++ b/src/devostasis/codeanalysis/bundle.py @@ -17,7 +17,7 @@ from ..workscope.operations import no_links from . import CONTRACT, ENGINE from .engine import RULES, evaluate, policy -from .git import object_digest +from .git import object_digest, source_selection MEMBERS = ("inputs.json", "policy.json", "analysis.json", "findings.sarif", "report.md") MAX_BUNDLE_BYTES = 128 * 1024 * 1024 @@ -92,6 +92,8 @@ def admit(raw, config): require(entry["status"] in ("ADMITTED", "UNAVAILABLE", "UNSUPPORTED", "OUT_OF_SCOPE"), "inventory availability unknown") if entry["kind"] == "blob": number(entry["size"], 0, 2**63 - 1) else: require(entry["size"] is None, "submodule must not claim blob size") + selection = source_selection(safe, entry["mode"], entry["kind"], entry["size"], config, len(admitted), source_total) + require((entry["status"], entry["reason"]) == selection, "unjustified source selection") included = safe is not None and (not config["include"] or any(safe == p or safe.startswith(p + "/") for p in config["include"])) excluded = safe is not None and any(safe == p or safe.startswith(p + "/") for p in config["exclude"]) if entry["status"] == "ADMITTED": @@ -210,6 +212,14 @@ def verify(root): return verify_content(read(root)) +def proof_scope(decoded): + """Operational verification limits, outside the immutable v1 artifact bytes.""" + omitted = any(e["kind"] == "blob" and e["status"] != "ADMITTED" for e in decoded["inputs.json"]["entries"]) + return {"source_tree": "VERIFIED", "admitted_blobs": "VERIFIED", + "omitted_blob_sizes": "UNPROVEN" if omitted else "NOT_APPLICABLE", + "history_ancestry": "UNPROVEN"} + + def publish(root, content): verify_content(content); root = no_links(root) root.parent.mkdir(parents=True, exist_ok=True); no_links(root.parent) diff --git a/src/devostasis/codeanalysis/cli.py b/src/devostasis/codeanalysis/cli.py index c053de9..29f0ae8 100644 --- a/src/devostasis/codeanalysis/cli.py +++ b/src/devostasis/codeanalysis/cli.py @@ -5,7 +5,7 @@ from ..canonical import canonical_bytes, loads from ..workscope.model import require -from .bundle import MAX_BUNDLE_BYTES, build, publish, verify +from .bundle import MAX_BUNDLE_BYTES, build, proof_scope, publish, verify from .consumer import compare, packet, verify_packet, work_evidence from .engine import default_policy from .git import collect @@ -42,7 +42,8 @@ def cmd(args): if args.operation == "compare": emit(compare(verify(args.before), verify(args.after)), args.output); return 0 decoded = verify(args.bundle) - if args.operation == "verify": emit({"status": "VERIFIED", "bundle_id": decoded["manifest.json"]["bundle_id"]}) + if args.operation == "verify": emit({"status": "VERIFIED", "bundle_id": decoded["manifest.json"]["bundle_id"], + "proof_scope": proof_scope(decoded)}) elif args.operation == "show": emit(decoded["analysis.json"], args.output) elif args.operation == "sarif": emit(decoded["findings.sarif"], args.output) elif args.operation == "packet": emit(packet(decoded, args.path, args.max_files, args.max_bytes), args.output) diff --git a/src/devostasis/codeanalysis/git.py b/src/devostasis/codeanalysis/git.py index 7dc1576..1dcb409 100644 --- a/src/devostasis/codeanalysis/git.py +++ b/src/devostasis/codeanalysis/git.py @@ -32,6 +32,27 @@ def blob_digest(data, object_format): return object_digest(data, object_format) +def source_selection(name, mode, kind, size, config, count, total): + """Deterministic acquisition priority; omitted blob sizes remain caller observations.""" + if name is None: + return "UNAVAILABLE", "UNSAFE_PATH" + included = not config["include"] or any(name == p or name.startswith(p + "/") for p in config["include"]) + excluded = any(name == p or name.startswith(p + "/") for p in config["exclude"]) + if not included or excluded: + return "OUT_OF_SCOPE", "POLICY_EXCLUSION" + if mode not in ("100644", "100755") or kind != "blob": + return "UNAVAILABLE", "SOURCE_LINK_OR_SUBMODULE" + if not name.endswith(".py"): + return "UNSUPPORTED", "LANGUAGE_UNSUPPORTED" + if size > config["budget"]["max_file_bytes"]: + return "UNAVAILABLE", "FILE_BYTE_CAP" + if count >= config["budget"]["max_files"]: + return "UNAVAILABLE", "FILE_COUNT_CAP" + if total + size > config["budget"]["max_total_bytes"]: + return "UNAVAILABLE", "TOTAL_BYTE_CAP" + return "ADMITTED", None + + class Git: def __init__(self, directory, seconds=120): self.directory = Path(directory).resolve() @@ -133,23 +154,10 @@ def collect(directory, ref, config, at=None): entry["path"] = path(filename.decode("utf-8")) except (UnicodeError, ValueError): entries.append(entry); continue - name = entry["path"] - included = not config["include"] or any(name == p or name.startswith(p + "/") for p in config["include"]) - excluded = any(name == p or name.startswith(p + "/") for p in config["exclude"]) - if not included or excluded: - entry.update(status="OUT_OF_SCOPE", reason="POLICY_EXCLUSION") - elif mode not in (b"100644", b"100755") or kind != b"blob": - entry.update(reason="SOURCE_LINK_OR_SUBMODULE") - elif not name.endswith(".py"): - entry.update(status="UNSUPPORTED", reason="LANGUAGE_UNSUPPORTED") - elif entry["size"] > config["budget"]["max_file_bytes"]: - entry.update(reason="FILE_BYTE_CAP") - elif len(selected) >= config["budget"]["max_files"]: - entry.update(reason="FILE_COUNT_CAP") - elif total + entry["size"] > config["budget"]["max_total_bytes"]: - entry.update(reason="TOTAL_BYTE_CAP") - else: - entry.update(status="ADMITTED", reason=None); selected.append(entry); total += entry["size"] + status, reason = source_selection(entry["path"], entry["mode"], entry["kind"], entry["size"], config, len(selected), total) + entry.update(status=status, reason=reason) + if status == "ADMITTED": + selected.append(entry); total += entry["size"] entries.append(entry) sources = {} if selected: diff --git a/tests/test_source_admission.py b/tests/test_source_admission.py new file mode 100644 index 0000000..21d8e9e --- /dev/null +++ b/tests/test_source_admission.py @@ -0,0 +1,61 @@ +"""Independent source-selection predicates and explicit omitted-size proof limits.""" + +from copy import deepcopy +import json + +import pytest + +from devostasis.cli import main +from devostasis.codeanalysis.bundle import build, proof_scope, publish, verify_content +from devostasis.codeanalysis.engine import default_policy +from devostasis.codeanalysis.git import collect +from test_code_analysis import AT, repository + + +@pytest.mark.parametrize("reason", ["UNSAFE_PATH", "SOURCE_LINK_OR_SUBMODULE", "FILE_BYTE_CAP", "FILE_COUNT_CAP", "TOTAL_BYTE_CAP"]) +def test_rehashed_source_omission_requires_its_actual_predicate(tmp_path, reason): + config = default_policy() + raw = collect(repository(tmp_path, {"safe.py": "pass\n"}), "HEAD", config, AT) + raw["entries"][0].update(status="UNAVAILABLE", reason=reason) + raw["sources"].clear() + with pytest.raises(ValueError, match="unjustified source selection"): + build(raw, config) + + +def test_valid_caps_and_policy_exclusions_still_replay(tmp_path): + repo = repository(tmp_path, {"a.py": "pass\n", "b.py": "pass\n", "vendor/c.py": "pass\n", "README.md": "fixture"}) + for budget, value in (("max_files", 1), ("max_file_bytes", 1), ("max_total_bytes", 6)): + config = default_policy(); config["budget"][budget] = value + if budget == "max_total_bytes": config["budget"]["max_file_bytes"] = value + raw = collect(repo, "HEAD", config, AT) + _, members = build(raw, config) + decoded = verify_content(members) + assert decoded["analysis.json"]["coverage"] == "PARTIAL" + assert proof_scope(decoded)["omitted_blob_sizes"] == "UNPROVEN" + assert any(e["status"] == "OUT_OF_SCOPE" for e in raw["entries"]) + + +def test_omitted_size_claim_does_not_become_independent_blob_proof(tmp_path, capsys): + config = default_policy() + raw = collect(repository(tmp_path, {"safe.py": "pass\n"}), "HEAD", config, AT) + # The tree authenticates the blob id, not this replacement size metadata. + changed = deepcopy(raw) + changed["entries"][0].update(status="UNAVAILABLE", reason="FILE_BYTE_CAP", size=2_000_000) + changed["sources"].clear() + _, members = build(changed, config) + decoded = verify_content(members) + assert decoded["analysis.json"]["coverage"] == "PARTIAL" + assert proof_scope(decoded)["omitted_blob_sizes"] == "UNPROVEN" + root = publish(tmp_path / "bundle", members) + assert main(["code", "verify", "--bundle", str(root)]) == 0 + receipt = json.loads(capsys.readouterr().out) + assert receipt["proof_scope"]["omitted_blob_sizes"] == "UNPROVEN" + assert receipt["proof_scope"]["history_ancestry"] == "UNPROVEN" + + +def test_admitted_size_is_independently_bound_to_original_bytes(tmp_path): + config = default_policy() + raw = collect(repository(tmp_path, {"safe.py": "pass\n"}), "HEAD", config, AT) + raw["entries"][0]["size"] += 1 + with pytest.raises(ValueError, match="source blob binding mismatch"): + build(raw, config) From 7a818043fca9e2660ce1ce802df37b764fdbe231 Mon Sep 17 00:00:00 2001 From: Unisphere Dev Date: Wed, 21 Oct 2026 14:16:21 +0300 Subject: [PATCH 25/33] Reconcile release stabilization and document the first report --- CHANGELOG.md | 11 +++++++++ README.md | 21 ++++++++++++++--- ROADMAP.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 193c949..bd79b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ contract or a policy always come with a version bump of that contract. Offline Repository Analysis, target E4, issue #72. +- Release-readiness repairs: reject ambiguous/malformed current CI inventory + before it can remove carried failures; preserve immutable revision time; + require exact bounded attempt identities and retain UNKNOWN history. Activity + shares current-record admission and exposes rejected verification inputs. +- Replay native source-selection predicates instead of accepting a recognized + omission reason alone. Operational verification explicitly distinguishes + proved source/tree bytes from unproven omitted sizes and history ancestry; + canonical v1 artifact bytes and existing lineages remain unchanged. +- Reconcile the October 21 release state/sequence and add a tested direct + first-report path; the v0.6.0 installation remains conditional on publication. + - New stdlib-only `code` companion: immutable local Git snapshot, Python 3.12 AST structure, six scoped findings, declared-module import cycles and bounded recent non-merge change hotspots. No source execution or forge dependency. diff --git a/README.md b/README.md index 31ee0a3..980dc80 100644 --- a/README.md +++ b/README.md @@ -211,15 +211,27 @@ For this unreleased checkout: pip install -e . ``` -After the 0.5.0 release tag is published: +After the 0.6.0 release tag is published: ```bash pip install git+https://github.com/drevendev/devostasis@v0.6.0 ``` -Observe one repository (a GitHub token is read from `DEVOSTASIS_GITHUB_TOKEN`, +Produce your first seven-Vital report without a configuration file: + +```bash +devostasis run --repo drevendev/Devostasis --store ./history-local +devostasis verify --bundle history-local/projects/github.com/drevendev/devostasis/latest +``` + +Open `history-local/projects/github.com/drevendev/devostasis/latest/report.md`. +The first successful run is a `BASELINE`; later runs compare with the previous +verified bundle. Unavailable API evidence remains explicit, and planning/debt +without configured registers or mappings remains undeclared/uninstrumented. + +A GitHub token is read from `DEVOSTASIS_GITHUB_TOKEN`, `GITHUB_TOKEN`, `GH_TOKEN` or `gh auth token`; public repositories work -without one, with a low rate limit): +without one, with a low rate limit. For separate observation and evaluation: ```bash devostasis observe --repo owner/name --out observations.json @@ -332,6 +344,9 @@ gauges, accepted band ordering and executable conformance vectors. The pending 0.2.0 adoption adds the accepted Vital repairs and durable Integrity history; 0.3.0 adds the separate Evidence to Action companion and 0.4.0 adds reproducible consumer handoff and accepted receipt/compatibility contracts. +0.5.0 adds bound consumer operations and portable private history; 0.6.0 adds +native offline Python analysis and verified source packets. The cumulative +release is still under review, including inherited history-admission repairs. Its GitLab work collector does not yet implement the core GitLab Vital adapter or the accepted Instruments carrier. diff --git a/ROADMAP.md b/ROADMAP.md index eff266e..dba863e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,71 @@ 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-08 +## Release state and next steps — 2026-10-21 + +The project is at **release stabilization**, with Phase A closed and Phase B +five of seven items implemented. B1 still has 62 named cases without executable +proof, and B7 still needs sustained outside-consumer evidence. The latest +published version is **v0.1.9**; 0.2.0 through 0.6.0 remain unreleased. + +The audit began with a clean working tree. All local and fetched remote heads +are ancestors of upstream `release/0.6.0` at `22a2894`, including the original +maintenance commits and the Dependabot correction merged through #79. Nine +earlier PRs remain open: #36/#37/#50/#53/#57/#59/#64/#69/#75. There is no +unrepresented development branch to recover. Inclusion in the release branch +does not publish its code on `master` or create a release tag. + +Upstream CI for the 0.6 source head `09d497f` passed on Python 3.12/3.13/3.14 +in [run 39030444073](https://github.com/drevendev/Devostasis/actions/runs/39030444073). +The Dependabot merge head `22a2894` also passed +[CI](https://github.com/drevendev/Devostasis/actions/runs/39169319950). +The old `action_required` run is historical evidence, not the current CI state. +These successful checks do not resolve the October 14/16/18 admission reviews +on #57/#75 or approve a later repaired head. + +### Delivery order + +1. **Finish the admission repairs before release.** Current malformed or + duplicate revision records must not erase carried failures or inflate the + sample. Admit timestamps before reconciliation, preserve immutable revision + time, check exact attempt identities without allocation by their declared + maximum, and retain UNKNOWN attempt evidence. Exercise actual four-bundle + evaluator/store chains and current/historical replay. Source omissions must + satisfy path/mode/scope and deterministic budget predicates; omitted blob + sizes remain acquisition claims, explicitly unproven by a tree proof. +2. **Propagate and review every final release head.** Preserve the prepared + sequence: corrected #57 into `release/0.2.0`, then #53, #59, #64, #69 and #75 + into `master`, with their respective tags under CONTRIBUTING.md. Carry the + history repair into descendants, include #79's Dependabot correction in + 0.6, and verify each changed head independently. Reconcile #36/#37/#50 after + their delivered commits land. A green matrix is evidence, not acceptance. +3. **Make the existing product straightforward to try.** The README now starts + with a direct single-repository report and its actual path (#78). Retain + source-install instructions until publication is confirmed. #63 still needs + actual scheduled Dependabot execution; the configuration correction alone + does not complete that operational acceptance. +4. **Complete assurance before increasing reach.** Work the outstanding + timestamp/lineage/store audit families (#35), the remaining exact B1 cases + (#20/#23) and independently proved source-history ancestry/counts (#73). + Keep partial coverage, historical compatibility and evidence limits visible. + +### Proposed next major code increment + +After releasing 0.6, the highest-impact code-only increment is **independently +verifiable history and executable assurance**: bounded commit/parent/tree +proofs for #73, hostile rehashed-history controls, and the missing executable +conformance boundaries for B1. This strengthens the proof objects on which +both work routing and native hotspots depend. It is a proposal, not acceptance +of a new research contract, a new release number, or completion of B1. + +The larger product milestone remains a real outside consumer completing work +from verified scopes with retained results and calendar history. Deployment +and scheduling remain owner-deferred in #71; they are not silently reinstated +by this audit. Once B7 and existing compatibility/review gates are met, extend +reach with the accepted core GitLab adapter and Instrument carrier. The +existing GitLab work collector does not implement that core adapter. + +## Historical 0.6 implementation snapshot — 2026-10-08 **0.6.0 Offline Repository Analysis (E4) is prepared on `zendreven/release-0.6.0`, carrying the pending 0.2–0.5 ancestry.** From 2003a9e8bfbf078f538ea9722d028aa771d1b82d Mon Sep 17 00:00:00 2001 From: Unisphere Dev Date: Wed, 21 Oct 2026 14:16:18 +0300 Subject: [PATCH 26/33] 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 22eb5cb90c9b72181f78ea47670ac69a3ada35d8 Mon Sep 17 00:00:00 2001 From: Unisphere Dev Date: Wed, 21 Oct 2026 14:26:04 +0300 Subject: [PATCH 27/33] 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 From b849923511ffef24b3b53d0ed01937ae706f60f8 Mon Sep 17 00:00:00 2001 From: Unisphere Dev Date: Wed, 21 Oct 2026 14:26:04 +0300 Subject: [PATCH 28/33] 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 From 3250ae33513fa50c76d4ebe6aba3a1a696321020 Mon Sep 17 00:00:00 2001 From: Unisphere Dev Date: Wed, 21 Oct 2026 16:16:01 +0300 Subject: [PATCH 29/33] feat(code): authenticate bounded Git history with exact legacy replay --- docs/code-analysis.md | 12 +- docs/spec/code-analysis.md | 73 +++- examples/code-v2/bundle/analysis.json | 1 + examples/code-v2/bundle/findings.sarif | 1 + examples/code-v2/bundle/inputs.json | 1 + examples/code-v2/bundle/manifest.json | 1 + examples/code-v2/bundle/policy.json | 1 + examples/code-v2/bundle/report.md | 52 +++ examples/code-v2/evidence.json | 1 + examples/code-v2/inventory.json | 1 + examples/code-v2/packet.json | 1 + examples/code-v2/policy.json | 1 + examples/code-v2/work-bundle/evidence.json | 1 + examples/code-v2/work-bundle/inventory.json | 1 + examples/code-v2/work-bundle/manifest.json | 1 + examples/code-v2/work-bundle/policy.json | 1 + examples/code-v2/work-bundle/report.md | 15 + examples/code-v2/work-bundle/scope.json | 1 + examples/code-v2/work-policy.json | 1 + schemas/code-history-proof.schema.json | 78 +++++ schemas/code-manifest-v2.schema.json | 108 ++++++ schemas/code-packet-v2.schema.json | 142 ++++++++ schemas/code-policy-v2.schema.json | 184 +++++++++++ scripts/make_code_examples.py | 9 +- src/devostasis/codeanalysis/__init__.py | 16 +- src/devostasis/codeanalysis/bundle.py | 33 +- src/devostasis/codeanalysis/consumer.py | 12 +- src/devostasis/codeanalysis/engine.py | 33 +- src/devostasis/codeanalysis/git.py | 17 +- src/devostasis/codeanalysis/history_proof.py | 331 +++++++++++++++++++ tests/test_code_analysis.py | 2 +- tests/test_code_history_proof.py | 141 ++++++++ tests/test_source_admission.py | 2 +- 33 files changed, 1233 insertions(+), 42 deletions(-) create mode 100644 examples/code-v2/bundle/analysis.json create mode 100644 examples/code-v2/bundle/findings.sarif create mode 100644 examples/code-v2/bundle/inputs.json create mode 100644 examples/code-v2/bundle/manifest.json create mode 100644 examples/code-v2/bundle/policy.json create mode 100644 examples/code-v2/bundle/report.md create mode 100644 examples/code-v2/evidence.json create mode 100644 examples/code-v2/inventory.json create mode 100644 examples/code-v2/packet.json create mode 100644 examples/code-v2/policy.json create mode 100644 examples/code-v2/work-bundle/evidence.json create mode 100644 examples/code-v2/work-bundle/inventory.json create mode 100644 examples/code-v2/work-bundle/manifest.json create mode 100644 examples/code-v2/work-bundle/policy.json create mode 100644 examples/code-v2/work-bundle/report.md create mode 100644 examples/code-v2/work-bundle/scope.json create mode 100644 examples/code-v2/work-policy.json create mode 100644 schemas/code-history-proof.schema.json create mode 100644 schemas/code-manifest-v2.schema.json create mode 100644 schemas/code-packet-v2.schema.json create mode 100644 schemas/code-policy-v2.schema.json create mode 100644 src/devostasis/codeanalysis/history_proof.py create mode 100644 tests/test_code_history_proof.py diff --git a/docs/code-analysis.md b/docs/code-analysis.md index ca29021..9e650a3 100644 --- a/docs/code-analysis.md +++ b/docs/code-analysis.md @@ -36,7 +36,7 @@ metadata and source bytes are included; keep real analysis bundles private when those inputs are private. The public example is entirely synthetic: ```sh -devostasis code verify --bundle examples/code/bundle +devostasis code verify --bundle examples/code-v2/bundle ``` Python coverage is COMPLETE, PARTIAL or NOT_APPLICABLE. Other languages remain @@ -46,8 +46,12 @@ the admitted Python scope, with the recorded static resolution assumptions. It does not prove runtime behavior or the absence of defects in other languages. The local identity derives from the recorded root commit IDs, not a forge URL; shallow or unrelated roots and changed policy make comparisons INCOMPARABLE. -History counts and root traversal are acquisition observations; their complete -ancestry is not independently proved by the source tree proof (issue #73). +New bundles use v2: authenticated raw commit/parent/tree/blob proof lets offline +verification derive roots, ancestry completeness, window selection and edit +counts independently. Counts use the declared MYERS_LF_BYTE_LINES_V1 algorithm, +with no rename heuristics. Missing objects or graph/byte/work caps remain PARTIAL; +partial roots cannot establish repository comparability. Historical v1 bundles +in examples/code still replay exactly and report history ancestry UNPROVEN. Likewise, a tree binds an omitted blob's identity, not its separately recorded size. Verification checks the deterministic selection predicates and marks omitted blob sizes `UNPROVEN` in its operational `proof_scope` receipt. Byte-cap @@ -94,4 +98,4 @@ No external runner, schedule, credentials or deployment is required for the offline commands. B1, B7 and release/review gates remain separate. See the [wire contract](spec/code-analysis.md) and -[synthetic report](../examples/code/bundle/report.md). +[synthetic v2 report](../examples/code-v2/bundle/report.md). diff --git a/docs/spec/code-analysis.md b/docs/spec/code-analysis.md index a10d93d..9da629a 100644 --- a/docs/spec/code-analysis.md +++ b/docs/spec/code-analysis.md @@ -4,7 +4,10 @@ Implementation-owned companion, introduced in 0.6.0. No accepted research Vital rule, ordering or Work Instrument judgement changes. Its identifiers are `devostasis.code-analysis.v1`, `devostasis.code-policy.v1`, `devostasis.code-engine.v1`, `devostasis.code-packet.v1` and -`devostasis.code-comparison.v1`. Case IDs below use the local DEV-CODE family. +`devostasis.code-comparison.v1` for historical artifacts. New collection defaults to +the corresponding `.v2` contracts and `devostasis.git-history-proof.v1`. Exact +policy/manifest/engine dispatch preserves v1 bytes; mixed or unknown tuples fail. +Case IDs below use the local DEV-CODE family. ## Admission and collection @@ -65,7 +68,7 @@ commit proof to 1 MiB, expanded leaves to 100000. Missing/extra/duplicate tree, path or source data is rejected. SHA-1/SHA-256 Git format is explicit. This establishes a commitment to source bytes, not authenticated commit authorship. -History contains `status`, unique `reasons`, `start`, `end`, `commits`. The +Historical v1 history contains `status`, unique `reasons`, `start`, `end`, `commits`. The window is inclusive, ends at observed_at and spans policy days. COMPLETE means the bounded collector exhausted its selected non-merge ancestor walk; PARTIAL retains admitted rows and cap/shallow/path gaps; UNAVAILABLE admits no @@ -74,7 +77,71 @@ Each commit has `revision`, `committed_at`, `changes`; changes contain `path`, `added`, `deleted` (both null for binary changes). Changes sort by path, commits by timestamp/full ID, with no duplicates. Counts, traversal roots and history completeness are recorded acquisition claims, not proved ancestry. -Offline verification replays those observations; proof hardening is #73. +Offline v1 verification replays those observations. Its operational proof scope +continues to report history ancestry UNPROVEN; v2 provides the proof below. + +## Independent v2 history proof + +V2 adds `history_proof` to inputs and boolean `roots_complete` to subject. The +closed proof has `contract`, `commits`, `trees`, `blobs`, `omitted`, `frontier`. +Object maps hold canonical base64 raw objects keyed by their framed Git hashes; +types, hashes, format widths and reachability are checked. Current source proof +objects are reused without duplication. Unused extra objects/omissions fail. +`omitted` maps required absent object IDs to OBJECT_UNAVAILABLE, OBJECT_BYTE_CAP, +OBJECT_COUNT_CAP, PROOF_BYTE_CAP or TIME_CAP. These reasons disclose acquisition +limits; they never establish that an omitted object was truly inaccessible. + +The verifier walks every parent edge from the pinned head, including merge +parents, without pruning by commit timestamps. Commit raw headers bind parents, +tree and committer epoch. Full roots are exactly the reachable parentless +commits. Missing/unvisited edges form the sorted frontier. An empty frontier is +required for `roots_complete=true` and ancestry COMPLETE. Partial repository +identity additionally hashes `unproven_ancestry_anchor=revision`; it cannot +establish a COMPARABLE pair. Full identity retains the object-format/root-set +formula. None of this authenticates a forge identity or commit author. + +V2 history also records `ancestry_status` and +`count_algorithm=MYERS_LF_BYTE_LINES_V1`. All proved non-merge ancestors in the +inclusive window are selected by committer time, newest first up to max_commits, +then emitted in ascending (time, full ID) order. Merge commits remain in the +proof graph. A selected commit compares its complete tree to its sole parent's +tree, or the empty tree for a proved root. There is no rename detection. + +Line counts use the shortest insertion/deletion edit distance over exact byte +lines split only at LF, retaining LF and an unterminated final line. CR is a +byte, not an extra separator. For distance D and old/new lengths N/M, added is +(D+M-N)/2 and deleted is (D+N-M)/2. NUL-containing inputs are BINARY, with both +counts null. Same-blob mode-only changes have zero counts. Git's configurable +numstat/rename heuristics are not this algorithm. A separate dynamic-programming +LCS test oracle checks these counts. + +Every change adds `count_status` and `reason`: VERIFIED, BINARY, UNAVAILABLE or +OUT_OF_SCOPE/POLICY_EXCLUSION. Include/exclude prefixes also govern history; +excluded blob bytes are never acquired for a diff. Missing blobs, unknown tree +presence, unsafe paths and exhausted diff limits cannot become zero edits. +Every commit adds `changes_complete`. Global COMPLETE requires complete ancestry, +no selected-commit truncation and complete in-scope change proof. PARTIAL retains +proved rows plus sorted reasons. History disabled remains NOT_REQUESTED while +bounded ancestry still proves or explicitly limits local identity. + +V2 policy adds max_ancestors (1..10000, default 2000), max_objects (1..50000, +default 10000), max_proof_bytes (1024..67108864, default 33554432), max_blob_bytes +(1..4194304, default 4194304), max_diff_work (1..10000000, default 2000000), and +max_diff_lines (1..200000, default 100000). Additional proof budgets exclude +already admitted current-source objects. Tree expansion and Myers work share +the deterministic work bound; references cap at 100000, frontier at 10000, +individual commits at 1 MiB and trees at 8 MiB. Total canonical bundle remains +128 MiB. No Git process, repository, network, external diff or clock is used by +replay. Missing proof never becomes a COMPLETE claim by rehashing the envelope. + +V2 hotspots add `unproven_changes` and `count_status`; numeric totals contain +only proved numeric edits. Coverage still describes the whole selected window. +Operational `proof_scope` reports ancestry VERIFIED/PARTIAL and counts +VERIFIED/PARTIAL/NOT_REQUESTED. Omitted source blob sizes remain UNPROVEN unless +not applicable: history proof does not universally resolve source omission debt. +The v1 schemas/examples remain frozen; separate `*-v2.schema.json`, +`code-history-proof.schema.json` and `examples/code-v2` publish this lineage. +Cross-engine comparisons are INCOMPARABLE with ANALYSIS_LINEAGE_CHANGED. ## Derived source evidence diff --git a/examples/code-v2/bundle/analysis.json b/examples/code-v2/bundle/analysis.json new file mode 100644 index 0000000..2d73f09 --- /dev/null +++ b/examples/code-v2/bundle/analysis.json @@ -0,0 +1 @@ +{"authority":"STATIC_SOURCE_EVIDENCE; no execution, independent acceptance, health score or merge authority","coverage":"COMPLETE","engine":"devostasis.code-engine.v2","files":[{"functions":[],"lines":null,"module":null,"object_id":"243c57287062ba5fb0d050828cdbcc0b91b39aa0","path":"README.md","reason":"LANGUAGE_UNSUPPORTED","status":"UNSUPPORTED"},{"functions":[],"lines":0,"module":"widget","object_id":"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391","path":"src/widget/__init__.py","reason":null,"status":"ANALYZED"},{"functions":[{"async":false,"complexity":2,"end_line":6,"line":3,"lines":4,"symbol":"choose"}],"lines":6,"module":"widget.app","object_id":"619dd2aa0d86bec82bdc88f2bbfc166e233feeb7","path":"src/widget/app.py","reason":null,"status":"ANALYZED"},{"functions":[{"async":false,"complexity":2,"end_line":7,"line":3,"lines":5,"symbol":"fallback"}],"lines":7,"module":"widget.parser","object_id":"e18e086cd787854f50a4270afa266031f783529f","path":"src/widget/parser.py","reason":null,"status":"ANALYZED"}],"findings":[{"end_line":1,"evidence":{"paths":["src/widget/app.py","src/widget/parser.py"]},"id":"sha256:f360634dd4075ebb04d4c979fe5a321757b8489275c06350a86e6347d99f3fac","level":"warning","line":1,"message":"Static import cycle","path":"src/widget/app.py","related_paths":["src/widget/app.py","src/widget/parser.py"],"rule":"DS-PY-IMPORT-CYCLE","symbol":"cycle:d205d439f837a19cc3f0467afed21a795b586999d0c79f8d3c9fba70482306cc"},{"end_line":6,"evidence":{"complexity":2,"limit":1},"id":"sha256:e61175cd8aee0c21f82268ef4094ea2febe3cf8cbced21d8ea71213776003b36","level":"warning","line":3,"message":"Function decision count exceeds policy","path":"src/widget/app.py","related_paths":[],"rule":"DS-PY-COMPLEXITY","symbol":"choose"},{"end_line":7,"evidence":{"complexity":2,"limit":1},"id":"sha256:b45437a83e217256f4e035e6996d84bee727811654eb04aa532103c937063f5c","level":"warning","line":3,"message":"Function decision count exceeds policy","path":"src/widget/parser.py","related_paths":[],"rule":"DS-PY-COMPLEXITY","symbol":"fallback"},{"end_line":7,"evidence":{"catches":"BaseException"},"id":"sha256:b5b403f6505e4667b581bc3af4381608765b90c45c553da2e63cb5e7a13360b1","level":"warning","line":6,"message":"Bare except also catches process control exceptions","path":"src/widget/parser.py","related_paths":[],"rule":"DS-PY-BARE-EXCEPT","symbol":"fallback.#1"}],"history":{"ancestry_status":"COMPLETE","count_algorithm":"MYERS_LF_BYTE_LINES_V1","end":"2026-10-08T12:00:00Z","reasons":[],"start":"2026-09-10T12:00:00Z","status":"COMPLETE"},"hotspots":[{"added":7,"binary_changes":0,"count_status":"VERIFIED","coverage":"COMPLETE","deleted":0,"path":"src/widget/parser.py","revisions":1,"unproven_changes":0},{"added":6,"binary_changes":0,"count_status":"VERIFIED","coverage":"COMPLETE","deleted":0,"path":"src/widget/app.py","revisions":1,"unproven_changes":0},{"added":1,"binary_changes":0,"count_status":"VERIFIED","coverage":"COMPLETE","deleted":0,"path":"README.md","revisions":1,"unproven_changes":0},{"added":0,"binary_changes":0,"count_status":"VERIFIED","coverage":"COMPLETE","deleted":0,"path":"src/widget/__init__.py","revisions":1,"unproven_changes":0}],"import_cycles":[["src/widget/app.py","src/widget/parser.py"]],"imports":[{"candidates":["src/widget/parser.py"],"from":"src/widget/app.py","line":1,"module":"widget.parser","status":"RESOLVED","to":"src/widget/parser.py"},{"candidates":["src/widget/app.py"],"from":"src/widget/parser.py","line":1,"module":"widget.app","status":"RESOLVED","to":"src/widget/app.py"}],"observed_at":"2026-10-08T12:00:00Z","subject":{"kind":"LOCAL_GIT","object_format":"sha1","repository_id":"sha256:82213dcc32cfbeb5300f0011e499ccfddfaa6bb8dd6006afd8a28bd700cf6ece","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","roots":["505ec5559236affd51ee6621b0eb54780bcd0e11"],"roots_complete":true,"tree":"a11d47f209c720b4036bab407ed91550e55529d0"}} \ No newline at end of file diff --git a/examples/code-v2/bundle/findings.sarif b/examples/code-v2/bundle/findings.sarif new file mode 100644 index 0000000..ed8a902 --- /dev/null +++ b/examples/code-v2/bundle/findings.sarif @@ -0,0 +1 @@ +{"$schema":"https://json.schemastore.org/sarif-2.1.0.json","runs":[{"properties":{"authority":"STATIC_SOURCE_EVIDENCE; no execution, independent acceptance, health score or merge authority","historyCoverage":"COMPLETE","sourceCoverage":"COMPLETE"},"results":[{"level":"warning","locations":[{"physicalLocation":{"artifactLocation":{"uri":"src/widget/app.py"},"region":{"endLine":1,"startLine":1}}}],"message":{"text":"Static import cycle"},"partialFingerprints":{"devostasisFindingId":"sha256:f360634dd4075ebb04d4c979fe5a321757b8489275c06350a86e6347d99f3fac"},"properties":{"evidence":{"paths":["src/widget/app.py","src/widget/parser.py"]},"relatedPaths":["src/widget/app.py","src/widget/parser.py"],"symbol":"cycle:d205d439f837a19cc3f0467afed21a795b586999d0c79f8d3c9fba70482306cc"},"ruleId":"DS-PY-IMPORT-CYCLE"},{"level":"warning","locations":[{"physicalLocation":{"artifactLocation":{"uri":"src/widget/app.py"},"region":{"endLine":6,"startLine":3}}}],"message":{"text":"Function decision count exceeds policy"},"partialFingerprints":{"devostasisFindingId":"sha256:e61175cd8aee0c21f82268ef4094ea2febe3cf8cbced21d8ea71213776003b36"},"properties":{"evidence":{"complexity":2,"limit":1},"relatedPaths":[],"symbol":"choose"},"ruleId":"DS-PY-COMPLEXITY"},{"level":"warning","locations":[{"physicalLocation":{"artifactLocation":{"uri":"src/widget/parser.py"},"region":{"endLine":7,"startLine":3}}}],"message":{"text":"Function decision count exceeds policy"},"partialFingerprints":{"devostasisFindingId":"sha256:b45437a83e217256f4e035e6996d84bee727811654eb04aa532103c937063f5c"},"properties":{"evidence":{"complexity":2,"limit":1},"relatedPaths":[],"symbol":"fallback"},"ruleId":"DS-PY-COMPLEXITY"},{"level":"warning","locations":[{"physicalLocation":{"artifactLocation":{"uri":"src/widget/parser.py"},"region":{"endLine":7,"startLine":6}}}],"message":{"text":"Bare except also catches process control exceptions"},"partialFingerprints":{"devostasisFindingId":"sha256:b5b403f6505e4667b581bc3af4381608765b90c45c553da2e63cb5e7a13360b1"},"properties":{"evidence":{"catches":"BaseException"},"relatedPaths":[],"symbol":"fallback.#1"},"ruleId":"DS-PY-BARE-EXCEPT"}],"tool":{"driver":{"name":"Devostasis Code Analysis","rules":[{"id":"DS-PY-BARE-EXCEPT","shortDescription":{"text":"Bare except also catches process control exceptions"}},{"id":"DS-PY-COMPLEXITY","shortDescription":{"text":"Function decision count exceeds policy"}},{"id":"DS-PY-FILE-SIZE","shortDescription":{"text":"File line count exceeds policy"}},{"id":"DS-PY-FUNCTION-SIZE","shortDescription":{"text":"Function line span exceeds policy"}},{"id":"DS-PY-IMPORT-CYCLE","shortDescription":{"text":"Static import cycle"}},{"id":"DS-PY-SYNTAX","shortDescription":{"text":"Python syntax cannot be parsed"}}],"version":"devostasis.code-engine.v2"}},"versionControlProvenance":[{"revisionId":"505ec5559236affd51ee6621b0eb54780bcd0e11"}]}],"version":"2.1.0"} \ No newline at end of file diff --git a/examples/code-v2/bundle/inputs.json b/examples/code-v2/bundle/inputs.json new file mode 100644 index 0000000..d11e86e --- /dev/null +++ b/examples/code-v2/bundle/inputs.json @@ -0,0 +1 @@ +{"entries":[{"kind":"blob","mode":"100644","object_id":"243c57287062ba5fb0d050828cdbcc0b91b39aa0","path":"README.md","path_base64":"UkVBRE1FLm1k","reason":"LANGUAGE_UNSUPPORTED","size":26,"status":"UNSUPPORTED"},{"kind":"blob","mode":"100644","object_id":"e69de29bb2d1d6434b8b29ae775ad8c2e48c5391","path":"src/widget/__init__.py","path_base64":"c3JjL3dpZGdldC9fX2luaXRfXy5weQ==","reason":null,"size":0,"status":"ADMITTED"},{"kind":"blob","mode":"100644","object_id":"619dd2aa0d86bec82bdc88f2bbfc166e233feeb7","path":"src/widget/app.py","path_base64":"c3JjL3dpZGdldC9hcHAucHk=","reason":null,"size":85,"status":"ADMITTED"},{"kind":"blob","mode":"100644","object_id":"e18e086cd787854f50a4270afa266031f783529f","path":"src/widget/parser.py","path_base64":"c3JjL3dpZGdldC9wYXJzZXIucHk=","reason":null,"size":90,"status":"ADMITTED"}],"history":{"ancestry_status":"COMPLETE","commits":[{"changes":[{"added":1,"count_status":"VERIFIED","deleted":0,"path":"README.md","reason":null},{"added":0,"count_status":"VERIFIED","deleted":0,"path":"src/widget/__init__.py","reason":null},{"added":6,"count_status":"VERIFIED","deleted":0,"path":"src/widget/app.py","reason":null},{"added":7,"count_status":"VERIFIED","deleted":0,"path":"src/widget/parser.py","reason":null}],"changes_complete":true,"committed_at":"2026-10-08T12:00:00Z","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11"}],"count_algorithm":"MYERS_LF_BYTE_LINES_V1","end":"2026-10-08T12:00:00Z","reasons":[],"start":"2026-09-10T12:00:00Z","status":"COMPLETE"},"history_proof":{"blobs":{"243c57287062ba5fb0d050828cdbcc0b91b39aa0":"U3ludGhldGljIHNvdXJjZSBmaXh0dXJlLgo="},"commits":{},"contract":"devostasis.git-history-proof.v1","frontier":[],"omitted":{},"trees":{}},"observed_at":"2026-10-08T12:00:00Z","proof":{"commit":"dHJlZSBhMTFkNDdmMjA5YzcyMGI0MDM2YmFiNDA3ZWQ5MTU1MGU1NTUyOWQwCmF1dGhvciBTeW50aGV0aWMgRml4dHVyZSA8Zml4dHVyZUBleGFtcGxlLmludmFsaWQ+IDE3OTE0NjA4MDAgKzAwMDAKY29tbWl0dGVyIFN5bnRoZXRpYyBGaXh0dXJlIDxmaXh0dXJlQGV4YW1wbGUuaW52YWxpZD4gMTc5MTQ2MDgwMCArMDAwMAoKU3ludGhldGljIG9mZmxpbmUgYW5hbHlzaXMgZml4dHVyZQo=","trees":{"1c953551c814aa05de7174253135818b33d38f70":"MTAwNjQ0IF9faW5pdF9fLnB5AOad4puy0dZDS4sprnda2MLkjFORMTAwNjQ0IGFwcC5weQBhndKqDYa+yCvciPK7/BZuIz/utzEwMDY0NCBwYXJzZXIucHkA4Y4IbNeHhU9QpCcK+iZgMfeDUp8=","a11d47f209c720b4036bab407ed91550e55529d0":"MTAwNjQ0IFJFQURNRS5tZAAkPFcocGK6X7DQUIKM28wLkbOaoDQwMDAwIHNyYwDT7WOOuuPU6/KOSC3gDnA2Ttck2w==","d3ed638ebae3d4ebf28e482de00e70364ed724db":"NDAwMDAgd2lkZ2V0AByVNVHIFKoF3nF0JTE1gYsz049w"}},"sources":{"src/widget/__init__.py":"","src/widget/app.py":"ZnJvbSAuIGltcG9ydCBwYXJzZXIKCmRlZiBjaG9vc2UodmFsdWUpOgogICAgaWYgdmFsdWU6CiAgICAgICAgcmV0dXJuIDEKICAgIHJldHVybiAwCg==","src/widget/parser.py":"ZnJvbSAuIGltcG9ydCBhcHAKCmRlZiBmYWxsYmFjaygpOgogICAgdHJ5OgogICAgICAgIHJldHVybiAxCiAgICBleGNlcHQ6CiAgICAgICAgcmV0dXJuIDAK"},"subject":{"kind":"LOCAL_GIT","object_format":"sha1","repository_id":"sha256:82213dcc32cfbeb5300f0011e499ccfddfaa6bb8dd6006afd8a28bd700cf6ece","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","roots":["505ec5559236affd51ee6621b0eb54780bcd0e11"],"roots_complete":true,"tree":"a11d47f209c720b4036bab407ed91550e55529d0"}} \ No newline at end of file diff --git a/examples/code-v2/bundle/manifest.json b/examples/code-v2/bundle/manifest.json new file mode 100644 index 0000000..19db3b0 --- /dev/null +++ b/examples/code-v2/bundle/manifest.json @@ -0,0 +1 @@ +{"bundle_id":"sha256:96a085f7a7d5d6091525e92271f41d7acfa338c1652caf33ae66dcbff6fbc609","contract":"devostasis.code-analysis.v2","engine":"devostasis.code-engine.v2","members":{"analysis.json":"sha256:b05dd89795478f8c967d5f9f2d6a93aa013b5c9075ee265ccb58873883ee3cd9","findings.sarif":"sha256:81af3c53aea50eb741c59f91525b8e453eb88f8016c0c3f1872301f0dd904d9a","inputs.json":"sha256:32b977e80d1b49c861b7aebeb23bddde1f4ba777b19fcb0649b4cf205e247e20","policy.json":"sha256:946751e06423bcc85d80a9c05d884e0d70d6fa504bf67f39444bcb4b3962e0cb","report.md":"sha256:99a2cc68889be28d42bfdfa0eec173c6d69d7ffdf9c96c888cd665e0daa89da7"},"subject":{"kind":"LOCAL_GIT","object_format":"sha1","repository_id":"sha256:82213dcc32cfbeb5300f0011e499ccfddfaa6bb8dd6006afd8a28bd700cf6ece","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","roots":["505ec5559236affd51ee6621b0eb54780bcd0e11"],"roots_complete":true,"tree":"a11d47f209c720b4036bab407ed91550e55529d0"}} \ No newline at end of file diff --git a/examples/code-v2/bundle/policy.json b/examples/code-v2/bundle/policy.json new file mode 100644 index 0000000..140e3c2 --- /dev/null +++ b/examples/code-v2/bundle/policy.json @@ -0,0 +1 @@ +{"budget":{"max_file_bytes":1048576,"max_files":2000,"max_total_bytes":16777216,"seconds":120},"contract":"devostasis.code-policy.v2","exclude":[".venv","build","dist","vendor","node_modules"],"grammar":"PYTHON_3_12","history":{"days":28,"enabled":true,"max_ancestors":2000,"max_blob_bytes":4194304,"max_commits":500,"max_diff_lines":100000,"max_diff_work":2000000,"max_objects":10000,"max_proof_bytes":33554432},"include":[],"limits":{"complexity":1,"file_lines":600,"function_lines":80},"source_roots":[".","src"],"version":"default-2"} \ No newline at end of file diff --git a/examples/code-v2/bundle/report.md b/examples/code-v2/bundle/report.md new file mode 100644 index 0000000..bd1218a --- /dev/null +++ b/examples/code-v2/bundle/report.md @@ -0,0 +1,52 @@ +# Offline repository analysis + +Revision: `505ec5559236affd51ee6621b0eb54780bcd0e11` + +Python grammar: 3.12. Static evidence only; repository code was not executed. + +Python source coverage: COMPLETE; history: COMPLETE. + +Unsupported and unavailable inputs do not establish an absence of findings. + +| Source status | Files | +| --- | ---: | +| ANALYZED | 3 | +| PARSE_ERROR | 0 | +| UNAVAILABLE | 0 | +| UNSUPPORTED | 1 | +| OUT_OF_SCOPE | 0 | + +## Findings + +| Path | Line | Rule | Symbol | Evidence | +| --- | ---: | --- | --- | --- | +| src/widget/app\.py | 1 | DS-PY-IMPORT-CYCLE | cycle:d205d439f837a19cc3f0467afed21a795b586999d0c79f8d3c9fba70482306cc | \{"paths":\["src/widget/app\.py","src/widget/parser\.py"\]\} | +| src/widget/app\.py | 3 | DS-PY-COMPLEXITY | choose | \{"complexity":2,"limit":1\} | +| src/widget/parser\.py | 3 | DS-PY-COMPLEXITY | fallback | \{"complexity":2,"limit":1\} | +| src/widget/parser\.py | 6 | DS-PY-BARE-EXCEPT | fallback\.<bare-except>\#1 | \{"catches":"BaseException"\} | + +Showing 4 of 4 findings; analysis.json and findings.sarif retain every finding. + +## Source gaps + +| Path | Status | Reason | +| --- | --- | --- | + +Showing 0 of 0 source gaps. + +## Recent change evidence + +Non-merge commits in [2026-09-10T12:00:00Z, 2026-10-08T12:00:00Z]. + +Counts under PARTIAL history are observed counts, not complete totals or productivity. + +| Path | Revisions | Added | Deleted | Coverage | +| --- | ---: | ---: | ---: | --- | +| src/widget/parser\.py | 1 | 7 | 0 | COMPLETE | +| src/widget/app\.py | 1 | 6 | 0 | COMPLETE | +| README\.md | 1 | 1 | 0 | COMPLETE | +| src/widget/\_\_init\_\_\.py | 1 | 0 | 0 | COMPLETE | + +Showing 4 of 4 paths. + +STATIC_SOURCE_EVIDENCE; no execution, independent acceptance, health score or merge authority diff --git a/examples/code-v2/evidence.json b/examples/code-v2/evidence.json new file mode 100644 index 0000000..37ff3cf --- /dev/null +++ b/examples/code-v2/evidence.json @@ -0,0 +1 @@ +{"contract":"devostasis.work.v2","kind":"evidence","sources":[{"observed_at":"2026-10-08T12:00:00Z","producer":"devostasis.code-engine.v2","profile":"sarif.v1","reason":null,"report":{"base64":"eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLnNjaGVtYXN0b3JlLm9yZy9zYXJpZi0yLjEuMC5qc29uIiwicnVucyI6W3sicHJvcGVydGllcyI6eyJhdXRob3JpdHkiOiJTVEFUSUNfU09VUkNFX0VWSURFTkNFOyBubyBleGVjdXRpb24sIGluZGVwZW5kZW50IGFjY2VwdGFuY2UsIGhlYWx0aCBzY29yZSBvciBtZXJnZSBhdXRob3JpdHkiLCJoaXN0b3J5Q292ZXJhZ2UiOiJDT01QTEVURSIsInNvdXJjZUNvdmVyYWdlIjoiQ09NUExFVEUifSwicmVzdWx0cyI6W3sibGV2ZWwiOiJ3YXJuaW5nIiwibG9jYXRpb25zIjpbeyJwaHlzaWNhbExvY2F0aW9uIjp7ImFydGlmYWN0TG9jYXRpb24iOnsidXJpIjoic3JjL3dpZGdldC9hcHAucHkifSwicmVnaW9uIjp7ImVuZExpbmUiOjEsInN0YXJ0TGluZSI6MX19fV0sIm1lc3NhZ2UiOnsidGV4dCI6IlN0YXRpYyBpbXBvcnQgY3ljbGUifSwicGFydGlhbEZpbmdlcnByaW50cyI6eyJkZXZvc3Rhc2lzRmluZGluZ0lkIjoic2hhMjU2OmYzNjA2MzRkZDQwNzVlYmIwNGQ0Yzk3OWZlNWEzMjE3NTdiODQ4OTI3NWMwNjM1MGE4NmU2MzQ3ZDk5ZjNmYWMifSwicHJvcGVydGllcyI6eyJldmlkZW5jZSI6eyJwYXRocyI6WyJzcmMvd2lkZ2V0L2FwcC5weSIsInNyYy93aWRnZXQvcGFyc2VyLnB5Il19LCJyZWxhdGVkUGF0aHMiOlsic3JjL3dpZGdldC9hcHAucHkiLCJzcmMvd2lkZ2V0L3BhcnNlci5weSJdLCJzeW1ib2wiOiJjeWNsZTpkMjA1ZDQzOWY4MzdhMTljYzNmMDQ2N2FmZWQyMWE3OTViNTg2OTk5ZDBjNzlmOGQzYzlmYmE3MDQ4MjMwNmNjIn0sInJ1bGVJZCI6IkRTLVBZLUlNUE9SVC1DWUNMRSJ9LHsibGV2ZWwiOiJ3YXJuaW5nIiwibG9jYXRpb25zIjpbeyJwaHlzaWNhbExvY2F0aW9uIjp7ImFydGlmYWN0TG9jYXRpb24iOnsidXJpIjoic3JjL3dpZGdldC9hcHAucHkifSwicmVnaW9uIjp7ImVuZExpbmUiOjYsInN0YXJ0TGluZSI6M319fV0sIm1lc3NhZ2UiOnsidGV4dCI6IkZ1bmN0aW9uIGRlY2lzaW9uIGNvdW50IGV4Y2VlZHMgcG9saWN5In0sInBhcnRpYWxGaW5nZXJwcmludHMiOnsiZGV2b3N0YXNpc0ZpbmRpbmdJZCI6InNoYTI1NjplNjExNzVjZDhhZWUwYzIxZjgyMjY4ZWY0MDk0ZWEyZmViZTNjZjhjYmNlZDIxZDhlYTcxMjEzNzc2MDAzYjM2In0sInByb3BlcnRpZXMiOnsiZXZpZGVuY2UiOnsiY29tcGxleGl0eSI6MiwibGltaXQiOjF9LCJyZWxhdGVkUGF0aHMiOltdLCJzeW1ib2wiOiJjaG9vc2UifSwicnVsZUlkIjoiRFMtUFktQ09NUExFWElUWSJ9LHsibGV2ZWwiOiJ3YXJuaW5nIiwibG9jYXRpb25zIjpbeyJwaHlzaWNhbExvY2F0aW9uIjp7ImFydGlmYWN0TG9jYXRpb24iOnsidXJpIjoic3JjL3dpZGdldC9wYXJzZXIucHkifSwicmVnaW9uIjp7ImVuZExpbmUiOjcsInN0YXJ0TGluZSI6M319fV0sIm1lc3NhZ2UiOnsidGV4dCI6IkZ1bmN0aW9uIGRlY2lzaW9uIGNvdW50IGV4Y2VlZHMgcG9saWN5In0sInBhcnRpYWxGaW5nZXJwcmludHMiOnsiZGV2b3N0YXNpc0ZpbmRpbmdJZCI6InNoYTI1NjpiNDU0MzdhODNlMjE3MjU2ZjRlMDM1ZTY5OTZkODRiZWU3Mjc4MTE2NTRlYjA0YWE1MzIxMDNjOTM3MDYzZjVjIn0sInByb3BlcnRpZXMiOnsiZXZpZGVuY2UiOnsiY29tcGxleGl0eSI6MiwibGltaXQiOjF9LCJyZWxhdGVkUGF0aHMiOltdLCJzeW1ib2wiOiJmYWxsYmFjayJ9LCJydWxlSWQiOiJEUy1QWS1DT01QTEVYSVRZIn0seyJsZXZlbCI6Indhcm5pbmciLCJsb2NhdGlvbnMiOlt7InBoeXNpY2FsTG9jYXRpb24iOnsiYXJ0aWZhY3RMb2NhdGlvbiI6eyJ1cmkiOiJzcmMvd2lkZ2V0L3BhcnNlci5weSJ9LCJyZWdpb24iOnsiZW5kTGluZSI6Nywic3RhcnRMaW5lIjo2fX19XSwibWVzc2FnZSI6eyJ0ZXh0IjoiQmFyZSBleGNlcHQgYWxzbyBjYXRjaGVzIHByb2Nlc3MgY29udHJvbCBleGNlcHRpb25zIn0sInBhcnRpYWxGaW5nZXJwcmludHMiOnsiZGV2b3N0YXNpc0ZpbmRpbmdJZCI6InNoYTI1NjpiNWI0MDNmNjUwNWU0NjY3YjU4MWJjM2FmNDM4MTYwODc2NWI5MGM0NWM1NTNkYTJlNjNjYjVlN2ExMzM2MGIxIn0sInByb3BlcnRpZXMiOnsiZXZpZGVuY2UiOnsiY2F0Y2hlcyI6IkJhc2VFeGNlcHRpb24ifSwicmVsYXRlZFBhdGhzIjpbXSwic3ltYm9sIjoiZmFsbGJhY2suPGJhcmUtZXhjZXB0PiMxIn0sInJ1bGVJZCI6IkRTLVBZLUJBUkUtRVhDRVBUIn1dLCJ0b29sIjp7ImRyaXZlciI6eyJuYW1lIjoiRGV2b3N0YXNpcyBDb2RlIEFuYWx5c2lzIiwicnVsZXMiOlt7ImlkIjoiRFMtUFktQkFSRS1FWENFUFQiLCJzaG9ydERlc2NyaXB0aW9uIjp7InRleHQiOiJCYXJlIGV4Y2VwdCBhbHNvIGNhdGNoZXMgcHJvY2VzcyBjb250cm9sIGV4Y2VwdGlvbnMifX0seyJpZCI6IkRTLVBZLUNPTVBMRVhJVFkiLCJzaG9ydERlc2NyaXB0aW9uIjp7InRleHQiOiJGdW5jdGlvbiBkZWNpc2lvbiBjb3VudCBleGNlZWRzIHBvbGljeSJ9fSx7ImlkIjoiRFMtUFktRklMRS1TSVpFIiwic2hvcnREZXNjcmlwdGlvbiI6eyJ0ZXh0IjoiRmlsZSBsaW5lIGNvdW50IGV4Y2VlZHMgcG9saWN5In19LHsiaWQiOiJEUy1QWS1GVU5DVElPTi1TSVpFIiwic2hvcnREZXNjcmlwdGlvbiI6eyJ0ZXh0IjoiRnVuY3Rpb24gbGluZSBzcGFuIGV4Y2VlZHMgcG9saWN5In19LHsiaWQiOiJEUy1QWS1JTVBPUlQtQ1lDTEUiLCJzaG9ydERlc2NyaXB0aW9uIjp7InRleHQiOiJTdGF0aWMgaW1wb3J0IGN5Y2xlIn19LHsiaWQiOiJEUy1QWS1TWU5UQVgiLCJzaG9ydERlc2NyaXB0aW9uIjp7InRleHQiOiJQeXRob24gc3ludGF4IGNhbm5vdCBiZSBwYXJzZWQifX1dLCJ2ZXJzaW9uIjoiZGV2b3N0YXNpcy5jb2RlLWVuZ2luZS52MiJ9fSwidmVyc2lvbkNvbnRyb2xQcm92ZW5hbmNlIjpbeyJyZXZpc2lvbklkIjoiNTA1ZWM1NTU5MjM2YWZmZDUxZWU2NjIxYjBlYjU0NzgwYmNkMGUxMSJ9XX1dLCJ2ZXJzaW9uIjoiMi4xLjAifQ==","digest":"sha256:81af3c53aea50eb741c59f91525b8e453eb88f8016c0c3f1872301f0dd904d9a"},"status":"COMPLETE","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}},"version":"1"}]} \ No newline at end of file diff --git a/examples/code-v2/inventory.json b/examples/code-v2/inventory.json new file mode 100644 index 0000000..fb5e3ac --- /dev/null +++ b/examples/code-v2/inventory.json @@ -0,0 +1 @@ +{"changes":{"items":[],"reasons":[],"status":"COMPLETE"},"contract":"devostasis.work.v2","issues":{"items":[],"reasons":[],"status":"COMPLETE"},"kind":"inventory","observed_at":"2026-10-08T12:00:00Z","receipts":[],"selection":{"changes":"OPEN_AND_REGISTERED","issues":[]},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}}} \ No newline at end of file diff --git a/examples/code-v2/packet.json b/examples/code-v2/packet.json new file mode 100644 index 0000000..9b1455f --- /dev/null +++ b/examples/code-v2/packet.json @@ -0,0 +1 @@ +{"analysis_bundle_id":"sha256:96a085f7a7d5d6091525e92271f41d7acfa338c1652caf33ae66dcbff6fbc609","authority":"PINNED_LOCAL_SOURCE_ONLY; no live freshness, independent acceptance or execution authority","budget":{"max_bytes":1048576,"max_files":20},"contract":"devostasis.code-packet.v2","files":[{"base64":"ZnJvbSAuIGltcG9ydCBwYXJzZXIKCmRlZiBjaG9vc2UodmFsdWUpOgogICAgaWYgdmFsdWU6CiAgICAgICAgcmV0dXJuIDEKICAgIHJldHVybiAwCg==","object_id":"619dd2aa0d86bec82bdc88f2bbfc166e233feeb7","path":"src/widget/app.py","size":85}],"packet_id":"sha256:0421fe90d4c30abbe1ca9c59e64a4fee7f06079fff195e1f96140a1a29e225c3","subject":{"kind":"LOCAL_GIT","object_format":"sha1","repository_id":"sha256:82213dcc32cfbeb5300f0011e499ccfddfaa6bb8dd6006afd8a28bd700cf6ece","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","roots":["505ec5559236affd51ee6621b0eb54780bcd0e11"],"roots_complete":true,"tree":"a11d47f209c720b4036bab407ed91550e55529d0"},"total_bytes":85} \ No newline at end of file diff --git a/examples/code-v2/policy.json b/examples/code-v2/policy.json new file mode 100644 index 0000000..140e3c2 --- /dev/null +++ b/examples/code-v2/policy.json @@ -0,0 +1 @@ +{"budget":{"max_file_bytes":1048576,"max_files":2000,"max_total_bytes":16777216,"seconds":120},"contract":"devostasis.code-policy.v2","exclude":[".venv","build","dist","vendor","node_modules"],"grammar":"PYTHON_3_12","history":{"days":28,"enabled":true,"max_ancestors":2000,"max_blob_bytes":4194304,"max_commits":500,"max_diff_lines":100000,"max_diff_work":2000000,"max_objects":10000,"max_proof_bytes":33554432},"include":[],"limits":{"complexity":1,"file_lines":600,"function_lines":80},"source_roots":[".","src"],"version":"default-2"} \ No newline at end of file diff --git a/examples/code-v2/work-bundle/evidence.json b/examples/code-v2/work-bundle/evidence.json new file mode 100644 index 0000000..37ff3cf --- /dev/null +++ b/examples/code-v2/work-bundle/evidence.json @@ -0,0 +1 @@ +{"contract":"devostasis.work.v2","kind":"evidence","sources":[{"observed_at":"2026-10-08T12:00:00Z","producer":"devostasis.code-engine.v2","profile":"sarif.v1","reason":null,"report":{"base64":"eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLnNjaGVtYXN0b3JlLm9yZy9zYXJpZi0yLjEuMC5qc29uIiwicnVucyI6W3sicHJvcGVydGllcyI6eyJhdXRob3JpdHkiOiJTVEFUSUNfU09VUkNFX0VWSURFTkNFOyBubyBleGVjdXRpb24sIGluZGVwZW5kZW50IGFjY2VwdGFuY2UsIGhlYWx0aCBzY29yZSBvciBtZXJnZSBhdXRob3JpdHkiLCJoaXN0b3J5Q292ZXJhZ2UiOiJDT01QTEVURSIsInNvdXJjZUNvdmVyYWdlIjoiQ09NUExFVEUifSwicmVzdWx0cyI6W3sibGV2ZWwiOiJ3YXJuaW5nIiwibG9jYXRpb25zIjpbeyJwaHlzaWNhbExvY2F0aW9uIjp7ImFydGlmYWN0TG9jYXRpb24iOnsidXJpIjoic3JjL3dpZGdldC9hcHAucHkifSwicmVnaW9uIjp7ImVuZExpbmUiOjEsInN0YXJ0TGluZSI6MX19fV0sIm1lc3NhZ2UiOnsidGV4dCI6IlN0YXRpYyBpbXBvcnQgY3ljbGUifSwicGFydGlhbEZpbmdlcnByaW50cyI6eyJkZXZvc3Rhc2lzRmluZGluZ0lkIjoic2hhMjU2OmYzNjA2MzRkZDQwNzVlYmIwNGQ0Yzk3OWZlNWEzMjE3NTdiODQ4OTI3NWMwNjM1MGE4NmU2MzQ3ZDk5ZjNmYWMifSwicHJvcGVydGllcyI6eyJldmlkZW5jZSI6eyJwYXRocyI6WyJzcmMvd2lkZ2V0L2FwcC5weSIsInNyYy93aWRnZXQvcGFyc2VyLnB5Il19LCJyZWxhdGVkUGF0aHMiOlsic3JjL3dpZGdldC9hcHAucHkiLCJzcmMvd2lkZ2V0L3BhcnNlci5weSJdLCJzeW1ib2wiOiJjeWNsZTpkMjA1ZDQzOWY4MzdhMTljYzNmMDQ2N2FmZWQyMWE3OTViNTg2OTk5ZDBjNzlmOGQzYzlmYmE3MDQ4MjMwNmNjIn0sInJ1bGVJZCI6IkRTLVBZLUlNUE9SVC1DWUNMRSJ9LHsibGV2ZWwiOiJ3YXJuaW5nIiwibG9jYXRpb25zIjpbeyJwaHlzaWNhbExvY2F0aW9uIjp7ImFydGlmYWN0TG9jYXRpb24iOnsidXJpIjoic3JjL3dpZGdldC9hcHAucHkifSwicmVnaW9uIjp7ImVuZExpbmUiOjYsInN0YXJ0TGluZSI6M319fV0sIm1lc3NhZ2UiOnsidGV4dCI6IkZ1bmN0aW9uIGRlY2lzaW9uIGNvdW50IGV4Y2VlZHMgcG9saWN5In0sInBhcnRpYWxGaW5nZXJwcmludHMiOnsiZGV2b3N0YXNpc0ZpbmRpbmdJZCI6InNoYTI1NjplNjExNzVjZDhhZWUwYzIxZjgyMjY4ZWY0MDk0ZWEyZmViZTNjZjhjYmNlZDIxZDhlYTcxMjEzNzc2MDAzYjM2In0sInByb3BlcnRpZXMiOnsiZXZpZGVuY2UiOnsiY29tcGxleGl0eSI6MiwibGltaXQiOjF9LCJyZWxhdGVkUGF0aHMiOltdLCJzeW1ib2wiOiJjaG9vc2UifSwicnVsZUlkIjoiRFMtUFktQ09NUExFWElUWSJ9LHsibGV2ZWwiOiJ3YXJuaW5nIiwibG9jYXRpb25zIjpbeyJwaHlzaWNhbExvY2F0aW9uIjp7ImFydGlmYWN0TG9jYXRpb24iOnsidXJpIjoic3JjL3dpZGdldC9wYXJzZXIucHkifSwicmVnaW9uIjp7ImVuZExpbmUiOjcsInN0YXJ0TGluZSI6M319fV0sIm1lc3NhZ2UiOnsidGV4dCI6IkZ1bmN0aW9uIGRlY2lzaW9uIGNvdW50IGV4Y2VlZHMgcG9saWN5In0sInBhcnRpYWxGaW5nZXJwcmludHMiOnsiZGV2b3N0YXNpc0ZpbmRpbmdJZCI6InNoYTI1NjpiNDU0MzdhODNlMjE3MjU2ZjRlMDM1ZTY5OTZkODRiZWU3Mjc4MTE2NTRlYjA0YWE1MzIxMDNjOTM3MDYzZjVjIn0sInByb3BlcnRpZXMiOnsiZXZpZGVuY2UiOnsiY29tcGxleGl0eSI6MiwibGltaXQiOjF9LCJyZWxhdGVkUGF0aHMiOltdLCJzeW1ib2wiOiJmYWxsYmFjayJ9LCJydWxlSWQiOiJEUy1QWS1DT01QTEVYSVRZIn0seyJsZXZlbCI6Indhcm5pbmciLCJsb2NhdGlvbnMiOlt7InBoeXNpY2FsTG9jYXRpb24iOnsiYXJ0aWZhY3RMb2NhdGlvbiI6eyJ1cmkiOiJzcmMvd2lkZ2V0L3BhcnNlci5weSJ9LCJyZWdpb24iOnsiZW5kTGluZSI6Nywic3RhcnRMaW5lIjo2fX19XSwibWVzc2FnZSI6eyJ0ZXh0IjoiQmFyZSBleGNlcHQgYWxzbyBjYXRjaGVzIHByb2Nlc3MgY29udHJvbCBleGNlcHRpb25zIn0sInBhcnRpYWxGaW5nZXJwcmludHMiOnsiZGV2b3N0YXNpc0ZpbmRpbmdJZCI6InNoYTI1NjpiNWI0MDNmNjUwNWU0NjY3YjU4MWJjM2FmNDM4MTYwODc2NWI5MGM0NWM1NTNkYTJlNjNjYjVlN2ExMzM2MGIxIn0sInByb3BlcnRpZXMiOnsiZXZpZGVuY2UiOnsiY2F0Y2hlcyI6IkJhc2VFeGNlcHRpb24ifSwicmVsYXRlZFBhdGhzIjpbXSwic3ltYm9sIjoiZmFsbGJhY2suPGJhcmUtZXhjZXB0PiMxIn0sInJ1bGVJZCI6IkRTLVBZLUJBUkUtRVhDRVBUIn1dLCJ0b29sIjp7ImRyaXZlciI6eyJuYW1lIjoiRGV2b3N0YXNpcyBDb2RlIEFuYWx5c2lzIiwicnVsZXMiOlt7ImlkIjoiRFMtUFktQkFSRS1FWENFUFQiLCJzaG9ydERlc2NyaXB0aW9uIjp7InRleHQiOiJCYXJlIGV4Y2VwdCBhbHNvIGNhdGNoZXMgcHJvY2VzcyBjb250cm9sIGV4Y2VwdGlvbnMifX0seyJpZCI6IkRTLVBZLUNPTVBMRVhJVFkiLCJzaG9ydERlc2NyaXB0aW9uIjp7InRleHQiOiJGdW5jdGlvbiBkZWNpc2lvbiBjb3VudCBleGNlZWRzIHBvbGljeSJ9fSx7ImlkIjoiRFMtUFktRklMRS1TSVpFIiwic2hvcnREZXNjcmlwdGlvbiI6eyJ0ZXh0IjoiRmlsZSBsaW5lIGNvdW50IGV4Y2VlZHMgcG9saWN5In19LHsiaWQiOiJEUy1QWS1GVU5DVElPTi1TSVpFIiwic2hvcnREZXNjcmlwdGlvbiI6eyJ0ZXh0IjoiRnVuY3Rpb24gbGluZSBzcGFuIGV4Y2VlZHMgcG9saWN5In19LHsiaWQiOiJEUy1QWS1JTVBPUlQtQ1lDTEUiLCJzaG9ydERlc2NyaXB0aW9uIjp7InRleHQiOiJTdGF0aWMgaW1wb3J0IGN5Y2xlIn19LHsiaWQiOiJEUy1QWS1TWU5UQVgiLCJzaG9ydERlc2NyaXB0aW9uIjp7InRleHQiOiJQeXRob24gc3ludGF4IGNhbm5vdCBiZSBwYXJzZWQifX1dLCJ2ZXJzaW9uIjoiZGV2b3N0YXNpcy5jb2RlLWVuZ2luZS52MiJ9fSwidmVyc2lvbkNvbnRyb2xQcm92ZW5hbmNlIjpbeyJyZXZpc2lvbklkIjoiNTA1ZWM1NTU5MjM2YWZmZDUxZWU2NjIxYjBlYjU0NzgwYmNkMGUxMSJ9XX1dLCJ2ZXJzaW9uIjoiMi4xLjAifQ==","digest":"sha256:81af3c53aea50eb741c59f91525b8e453eb88f8016c0c3f1872301f0dd904d9a"},"status":"COMPLETE","subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}},"version":"1"}]} \ No newline at end of file diff --git a/examples/code-v2/work-bundle/inventory.json b/examples/code-v2/work-bundle/inventory.json new file mode 100644 index 0000000..fb5e3ac --- /dev/null +++ b/examples/code-v2/work-bundle/inventory.json @@ -0,0 +1 @@ +{"changes":{"items":[],"reasons":[],"status":"COMPLETE"},"contract":"devostasis.work.v2","issues":{"items":[],"reasons":[],"status":"COMPLETE"},"kind":"inventory","observed_at":"2026-10-08T12:00:00Z","receipts":[],"selection":{"changes":"OPEN_AND_REGISTERED","issues":[]},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}}} \ No newline at end of file diff --git a/examples/code-v2/work-bundle/manifest.json b/examples/code-v2/work-bundle/manifest.json new file mode 100644 index 0000000..5576f55 --- /dev/null +++ b/examples/code-v2/work-bundle/manifest.json @@ -0,0 +1 @@ +{"bundle_id":"sha256:ebd6b9ecd6dbfc8bfa892eeaac2946c197b59955e1314dcffb600d0b20d30c9a","contract":"devostasis.work.v2","engine":"devostasis.work-engine.v2","kind":"bundle","members":{"evidence.json":"sha256:6dd5dafdb0c30195aa78bb67f84b42439e41a4b6f9afd1853f54c76c2531b7b2","inventory.json":"sha256:5090e7b9655fd3d9939f8ba7c919a2659e045d75ce63ea40856ec073dfcce347","policy.json":"sha256:05cc5cc2caca768462c0e31b9da37aad7932663e12a10fee8ef127f3b2a1d683","report.md":"sha256:3111bd131fc868a4c5d9468227339d1bd28e2a15578f31f41fe567701f42fb05","scope.json":"sha256:b4c95d1f9bc3e670804f59061eea16be8508693723d9035835ab27046a57e184"},"vitals_anchor":null} \ No newline at end of file diff --git a/examples/code-v2/work-bundle/policy.json b/examples/code-v2/work-bundle/policy.json new file mode 100644 index 0000000..8b56348 --- /dev/null +++ b/examples/code-v2/work-bundle/policy.json @@ -0,0 +1 @@ +{"actor":"worker","allow_merge_train":false,"contract":"devostasis.work.v2","criteria":[],"dispositions":[],"kind":"policy","minimum_approvals":1,"priorities":{},"queue_order":["review","finish_merge","implement_issue","research","analyze_code"],"read_budget":{"max_bytes":200000,"max_files":20},"requests":[],"required_checks":[],"roles":["all"],"ttl_seconds":3600,"version":"example-1"} \ No newline at end of file diff --git a/examples/code-v2/work-bundle/report.md b/examples/code-v2/work-bundle/report.md new file mode 100644 index 0000000..1804a7d --- /dev/null +++ b/examples/code-v2/work-bundle/report.md @@ -0,0 +1,15 @@ +# Devostasis work scope + +Observed: 2026-10-08T12:00:00Z · expires: 2026-10-08T13:00:00Z + +Read-only proposal. Recheck source state and consumer authorization before execution. + +| Queue | Eligibility | Source | Action | Reasons | +| --- | --- | --- | --- | --- | +| analyze_code | READY | sha256:1b83f3aaa8c87b39bd0abd38babbd75a599fb7398c85f3e28f662ac6d8f7c85d | analyze_code | | +| analyze_code | READY | sha256:021413c4afe15592bb4926c5c54e09e05d7a5f61ac0fc3f4bff2aa084d46ce4f | analyze_code | | +| analyze_code | READY | sha256:cda057b57b75e76615b0b488dc8fba3d1d5b438aab88e0c4007d0e914ed8e890 | analyze_code | | +| analyze_code | READY | sha256:5f5567f8a3ad51e6dc111d6fae9d0351b39fa9d061d00fb694ec7f9b69f81bc4 | analyze_code | | + +Coverage: changes=COMPLETE, issues=COMPLETE +Excluded: 0; evidence gaps: 0. diff --git a/examples/code-v2/work-bundle/scope.json b/examples/code-v2/work-bundle/scope.json new file mode 100644 index 0000000..7cd96ba --- /dev/null +++ b/examples/code-v2/work-bundle/scope.json @@ -0,0 +1 @@ +{"authority":"Read-only proposal. Recheck source state and consumer authorization before execution.","contract":"devostasis.work.v2","coverage":{"changes":"COMPLETE","issues":"COMPLETE"},"excluded":[],"expires_at":"2026-10-08T13:00:00Z","gaps":[],"inputs":{"evidence":"sha256:6dd5dafdb0c30195aa78bb67f84b42439e41a4b6f9afd1853f54c76c2531b7b2","inventory":"sha256:5090e7b9655fd3d9939f8ba7c919a2659e045d75ce63ea40856ec073dfcce347","policy":"sha256:05cc5cc2caca768462c0e31b9da37aad7932663e12a10fee8ef127f3b2a1d683"},"items":[{"acceptance":["Inspect the rule and location; reproduce or disposition the finding, then rerun the producer."],"action":"analyze_code","continuation":false,"criterion":"DS-PY-COMPLEXITY","dependencies":[],"eligibility":"READY","evidence":["sha256:05cc5cc2caca768462c0e31b9da37aad7932663e12a10fee8ef127f3b2a1d683","sha256:5090e7b9655fd3d9939f8ba7c919a2659e045d75ce63ea40856ec073dfcce347","sha256:6dd5dafdb0c30195aa78bb67f84b42439e41a4b6f9afd1853f54c76c2531b7b2","sha256:81af3c53aea50eb741c59f91525b8e453eb88f8016c0c3f1872301f0dd904d9a"],"executor":"worker","fingerprint":"sha256:5a4b2f32f1f8be7cc32c21c55b217732bec9668f51e473426d61ca9acdc89796","id":"sha256:1a80f069dbede63d29605f66c4fb9794baca0b0b419589c66bf24dc00640e633","implementation_owner":null,"merge_gate":null,"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"analyze_code","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/widget/app.py"],"symbols":[],"total_paths":1},"reasons":[],"revision":{"content":"505ec5559236affd51ee6621b0eb54780bcd0e11"},"source":"sha256:1b83f3aaa8c87b39bd0abd38babbd75a599fb7398c85f3e28f662ac6d8f7c85d","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}},"title":"Static analysis reported this rule at this location."},{"acceptance":["Inspect the rule and location; reproduce or disposition the finding, then rerun the producer."],"action":"analyze_code","continuation":false,"criterion":"DS-PY-BARE-EXCEPT","dependencies":[],"eligibility":"READY","evidence":["sha256:05cc5cc2caca768462c0e31b9da37aad7932663e12a10fee8ef127f3b2a1d683","sha256:5090e7b9655fd3d9939f8ba7c919a2659e045d75ce63ea40856ec073dfcce347","sha256:6dd5dafdb0c30195aa78bb67f84b42439e41a4b6f9afd1853f54c76c2531b7b2","sha256:81af3c53aea50eb741c59f91525b8e453eb88f8016c0c3f1872301f0dd904d9a"],"executor":"worker","fingerprint":"sha256:907c0de8929c42a350a6883d1da2365a5bbfaf6b8d9a0a2ca9b6a9fd144ddac2","id":"sha256:1f870a65315bc8f6bbe3cc36c842b93b90ee43289c651e9a10c2fd6499a333f3","implementation_owner":null,"merge_gate":null,"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"analyze_code","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/widget/parser.py"],"symbols":[],"total_paths":1},"reasons":[],"revision":{"content":"505ec5559236affd51ee6621b0eb54780bcd0e11"},"source":"sha256:021413c4afe15592bb4926c5c54e09e05d7a5f61ac0fc3f4bff2aa084d46ce4f","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}},"title":"Static analysis reported this rule at this location."},{"acceptance":["Inspect the rule and location; reproduce or disposition the finding, then rerun the producer."],"action":"analyze_code","continuation":false,"criterion":"DS-PY-COMPLEXITY","dependencies":[],"eligibility":"READY","evidence":["sha256:05cc5cc2caca768462c0e31b9da37aad7932663e12a10fee8ef127f3b2a1d683","sha256:5090e7b9655fd3d9939f8ba7c919a2659e045d75ce63ea40856ec073dfcce347","sha256:6dd5dafdb0c30195aa78bb67f84b42439e41a4b6f9afd1853f54c76c2531b7b2","sha256:81af3c53aea50eb741c59f91525b8e453eb88f8016c0c3f1872301f0dd904d9a"],"executor":"worker","fingerprint":"sha256:15691e8ddb6f6efc7691344630bb94c27460a54f11097d8f81998e0ca3a805c9","id":"sha256:47d088d2ea2f8b2599670c8907f2f18d9d3d2f50ee729006cfe505c00878450a","implementation_owner":null,"merge_gate":null,"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"analyze_code","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/widget/parser.py"],"symbols":[],"total_paths":1},"reasons":[],"revision":{"content":"505ec5559236affd51ee6621b0eb54780bcd0e11"},"source":"sha256:cda057b57b75e76615b0b488dc8fba3d1d5b438aab88e0c4007d0e914ed8e890","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}},"title":"Static analysis reported this rule at this location."},{"acceptance":["Inspect the rule and location; reproduce or disposition the finding, then rerun the producer."],"action":"analyze_code","continuation":false,"criterion":"DS-PY-IMPORT-CYCLE","dependencies":[],"eligibility":"READY","evidence":["sha256:05cc5cc2caca768462c0e31b9da37aad7932663e12a10fee8ef127f3b2a1d683","sha256:5090e7b9655fd3d9939f8ba7c919a2659e045d75ce63ea40856ec073dfcce347","sha256:6dd5dafdb0c30195aa78bb67f84b42439e41a4b6f9afd1853f54c76c2531b7b2","sha256:81af3c53aea50eb741c59f91525b8e453eb88f8016c0c3f1872301f0dd904d9a"],"executor":"worker","fingerprint":"sha256:4cd339c1e44a86642fdb8b7682684e7ce19dc27f2a753beda503aeee80cf587d","id":"sha256:8c82d0679a8170a163c69450af9d44bc0284408ec64c6dae0c83e085a5ae022a","implementation_owner":null,"merge_gate":null,"priority":{"rank":1000,"rationale":"No consumer business priority declared."},"queue":"analyze_code","read_set":{"budget":{"max_bytes":200000,"max_files":20},"paths":["src/widget/app.py"],"symbols":[],"total_paths":1},"reasons":[],"revision":{"content":"505ec5559236affd51ee6621b0eb54780bcd0e11"},"source":"sha256:5f5567f8a3ad51e6dc111d6fae9d0351b39fa9d061d00fb694ec7f9b69f81bc4","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}},"title":"Static analysis reported this rule at this location."}],"kind":"scope","observed_at":"2026-10-08T12:00:00Z","planned_at":"2026-10-08T12:00:00Z","policy_version":"example-1","queues":{"analyze_code":["sha256:1a80f069dbede63d29605f66c4fb9794baca0b0b419589c66bf24dc00640e633","sha256:1f870a65315bc8f6bbe3cc36c842b93b90ee43289c651e9a10c2fd6499a333f3","sha256:47d088d2ea2f8b2599670c8907f2f18d9d3d2f50ee729006cfe505c00878450a","sha256:8c82d0679a8170a163c69450af9d44bc0284408ec64c6dae0c83e085a5ae022a"],"finish_merge":[],"implement_issue":[],"research":[],"review":[]},"selection":{"changes":"OPEN_AND_REGISTERED","issues":[]},"subject":{"context":"CANONICAL","endpoint":"https://api.github.com","locator":"acme/widget","project_id":"123456","provider":"github","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_binding":{"contract":"devostasis.work-source.v1","kind":"DEFAULT_BRANCH","project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"},"ref":"main","revision":"505ec5559236affd51ee6621b0eb54780bcd0e11","source_locator":"acme/widget","source_project":{"endpoint":"https://api.github.com","project_id":"123456","provider":"github"}}}} \ No newline at end of file diff --git a/examples/code-v2/work-policy.json b/examples/code-v2/work-policy.json new file mode 100644 index 0000000..8b56348 --- /dev/null +++ b/examples/code-v2/work-policy.json @@ -0,0 +1 @@ +{"actor":"worker","allow_merge_train":false,"contract":"devostasis.work.v2","criteria":[],"dispositions":[],"kind":"policy","minimum_approvals":1,"priorities":{},"queue_order":["review","finish_merge","implement_issue","research","analyze_code"],"read_budget":{"max_bytes":200000,"max_files":20},"requests":[],"required_checks":[],"roles":["all"],"ttl_seconds":3600,"version":"example-1"} \ No newline at end of file diff --git a/schemas/code-history-proof.schema.json b/schemas/code-history-proof.schema.json new file mode 100644 index 0000000..ac9e3bb --- /dev/null +++ b/schemas/code-history-proof.schema.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/drevendev/Devostasis/schemas/code-history-proof.schema.json", + "description": "Shape of bounded proof v1; executable admission authenticates Git object types/hashes, reachability, frontier, budgets and every derived history claim.", + "type": "object", + "additionalProperties": false, + "required": [ + "contract", + "commits", + "trees", + "blobs", + "omitted", + "frontier" + ], + "properties": { + "contract": { + "const": "devostasis.git-history-proof.v1" + }, + "commits": { + "type": "object", + "maxProperties": 50000, + "propertyNames": { + "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$" + }, + "additionalProperties": { + "type": "string", + "contentEncoding": "base64" + } + }, + "trees": { + "type": "object", + "maxProperties": 50000, + "propertyNames": { + "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$" + }, + "additionalProperties": { + "type": "string", + "contentEncoding": "base64" + } + }, + "blobs": { + "type": "object", + "maxProperties": 50000, + "propertyNames": { + "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$" + }, + "additionalProperties": { + "type": "string", + "contentEncoding": "base64" + } + }, + "omitted": { + "type": "object", + "maxProperties": 100000, + "propertyNames": { + "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$" + }, + "additionalProperties": { + "enum": [ + "OBJECT_UNAVAILABLE", + "OBJECT_BYTE_CAP", + "OBJECT_COUNT_CAP", + "PROOF_BYTE_CAP", + "TIME_CAP" + ] + } + }, + "frontier": { + "type": "array", + "maxItems": 10000, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$" + } + } + } +} diff --git a/schemas/code-manifest-v2.schema.json b/schemas/code-manifest-v2.schema.json new file mode 100644 index 0000000..c621f7a --- /dev/null +++ b/schemas/code-manifest-v2.schema.json @@ -0,0 +1,108 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/drevendev/Devostasis/schemas/code-manifest-v2.schema.json", + "description": "Shape constraints; executable admission additionally enforces canonical bytes, source proofs, unique sorted paths, cross-field limits and exact replay.", + "type": "object", + "additionalProperties": false, + "required": [ + "contract", + "engine", + "subject", + "members", + "bundle_id" + ], + "properties": { + "contract": { + "const": "devostasis.code-analysis.v2" + }, + "engine": { + "const": "devostasis.code-engine.v2" + }, + "subject": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "object_format", + "roots", + "repository_id", + "revision", + "tree", + "roots_complete" + ], + "properties": { + "kind": { + "const": "LOCAL_GIT" + }, + "object_format": { + "enum": [ + "sha1", + "sha256" + ] + }, + "roots": { + "type": "array", + "minItems": 0, + "maxItems": 10000, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$" + } + }, + "repository_id": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "revision": { + "type": "string", + "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$" + }, + "tree": { + "type": "string", + "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$" + }, + "roots_complete": { + "type": "boolean" + } + } + }, + "members": { + "type": "object", + "additionalProperties": false, + "required": [ + "inputs.json", + "policy.json", + "analysis.json", + "findings.sarif", + "report.md" + ], + "properties": { + "inputs.json": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "policy.json": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "analysis.json": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "findings.sarif": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "report.md": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + } + } + }, + "bundle_id": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + } + } +} diff --git a/schemas/code-packet-v2.schema.json b/schemas/code-packet-v2.schema.json new file mode 100644 index 0000000..f062495 --- /dev/null +++ b/schemas/code-packet-v2.schema.json @@ -0,0 +1,142 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/drevendev/Devostasis/schemas/code-packet-v2.schema.json", + "description": "Shape constraints; executable admission additionally enforces canonical bytes, source proofs, unique sorted paths, cross-field limits and exact replay.", + "type": "object", + "additionalProperties": false, + "required": [ + "contract", + "analysis_bundle_id", + "subject", + "budget", + "files", + "total_bytes", + "authority", + "packet_id" + ], + "properties": { + "contract": { + "const": "devostasis.code-packet.v2" + }, + "analysis_bundle_id": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "subject": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "object_format", + "roots", + "repository_id", + "revision", + "tree", + "roots_complete" + ], + "properties": { + "kind": { + "const": "LOCAL_GIT" + }, + "object_format": { + "enum": [ + "sha1", + "sha256" + ] + }, + "roots": { + "type": "array", + "minItems": 0, + "maxItems": 10000, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$" + } + }, + "repository_id": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "revision": { + "type": "string", + "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$" + }, + "tree": { + "type": "string", + "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$" + }, + "roots_complete": { + "type": "boolean" + } + } + }, + "budget": { + "type": "object", + "additionalProperties": false, + "required": [ + "max_files", + "max_bytes" + ], + "properties": { + "max_files": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + "max_bytes": { + "type": "integer", + "minimum": 1, + "maximum": 67108864 + } + } + }, + "files": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "object_id", + "size", + "base64" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[^\\\\\\u0000-\\u001f]+$" + }, + "object_id": { + "type": "string", + "pattern": "^[0-9a-f]{40}([0-9a-f]{24})?$" + }, + "size": { + "type": "integer", + "minimum": 0, + "maximum": 67108864 + }, + "base64": { + "type": "string", + "contentEncoding": "base64" + } + } + } + }, + "total_bytes": { + "type": "integer", + "minimum": 0, + "maximum": 67108864 + }, + "authority": { + "const": "PINNED_LOCAL_SOURCE_ONLY; no live freshness, independent acceptance or execution authority" + }, + "packet_id": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + } + } +} diff --git a/schemas/code-policy-v2.schema.json b/schemas/code-policy-v2.schema.json new file mode 100644 index 0000000..05444b2 --- /dev/null +++ b/schemas/code-policy-v2.schema.json @@ -0,0 +1,184 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/drevendev/Devostasis/schemas/code-policy-v2.schema.json", + "description": "Shape constraints; executable admission additionally enforces canonical bytes, source proofs, unique sorted paths, cross-field limits and exact replay.", + "type": "object", + "additionalProperties": false, + "required": [ + "contract", + "version", + "grammar", + "include", + "exclude", + "source_roots", + "budget", + "limits", + "history" + ], + "properties": { + "contract": { + "const": "devostasis.code-policy.v2" + }, + "version": { + "type": "string", + "minLength": 1 + }, + "grammar": { + "const": "PYTHON_3_12" + }, + "include": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[^\\\\\\u0000-\\u001f]+$" + } + }, + "exclude": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[^\\\\\\u0000-\\u001f]+$" + } + }, + "source_roots": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "uniqueItems": true, + "items": { + "anyOf": [ + { + "const": "." + }, + { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[^\\\\\\u0000-\\u001f]+$" + } + ] + } + }, + "budget": { + "type": "object", + "additionalProperties": false, + "required": [ + "max_files", + "max_file_bytes", + "max_total_bytes", + "seconds" + ], + "properties": { + "max_files": { + "type": "integer", + "minimum": 1, + "maximum": 5000 + }, + "max_file_bytes": { + "type": "integer", + "minimum": 1, + "maximum": 4194304 + }, + "max_total_bytes": { + "type": "integer", + "minimum": 1, + "maximum": 67108864 + }, + "seconds": { + "type": "integer", + "minimum": 1, + "maximum": 3600 + } + } + }, + "limits": { + "type": "object", + "additionalProperties": false, + "required": [ + "complexity", + "function_lines", + "file_lines" + ], + "properties": { + "complexity": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + }, + "function_lines": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + }, + "file_lines": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + } + } + }, + "history": { + "type": "object", + "additionalProperties": false, + "required": [ + "enabled", + "days", + "max_commits", + "max_ancestors", + "max_objects", + "max_proof_bytes", + "max_blob_bytes", + "max_diff_work", + "max_diff_lines" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "days": { + "type": "integer", + "minimum": 1, + "maximum": 3650 + }, + "max_commits": { + "type": "integer", + "minimum": 1, + "maximum": 10000 + }, + "max_ancestors": { + "type": "integer", + "minimum": 1, + "maximum": 10000 + }, + "max_objects": { + "type": "integer", + "minimum": 1, + "maximum": 50000 + }, + "max_proof_bytes": { + "type": "integer", + "minimum": 1024, + "maximum": 67108864 + }, + "max_blob_bytes": { + "type": "integer", + "minimum": 1, + "maximum": 4194304 + }, + "max_diff_work": { + "type": "integer", + "minimum": 1, + "maximum": 10000000 + }, + "max_diff_lines": { + "type": "integer", + "minimum": 1, + "maximum": 200000 + } + } + } + } +} diff --git a/scripts/make_code_examples.py b/scripts/make_code_examples.py index 609f014..4d17288 100644 --- a/scripts/make_code_examples.py +++ b/scripts/make_code_examples.py @@ -22,8 +22,8 @@ AT = "2026-10-08T12:00:00Z" -def generate(): - config = default_policy() +def generate(*, legacy=True): + config = default_policy(legacy=legacy) config["limits"]["complexity"] = 1 with tempfile.TemporaryDirectory(prefix="devostasis-synthetic-code-") as directory: repo = Path(directory) @@ -63,7 +63,8 @@ def git(*args): if __name__ == "__main__": - destination = ROOT / "examples/code" - for name, data in generate().items(): + legacy = "--v2" not in sys.argv[1:] + destination = ROOT / ("examples/code" if legacy else "examples/code-v2") + for name, data in generate(legacy=legacy).items(): file = destination / name; file.parent.mkdir(parents=True, exist_ok=True); file.write_bytes(data) print(destination) diff --git a/src/devostasis/codeanalysis/__init__.py b/src/devostasis/codeanalysis/__init__.py index 20e070c..153f661 100644 --- a/src/devostasis/codeanalysis/__init__.py +++ b/src/devostasis/codeanalysis/__init__.py @@ -1,7 +1,13 @@ """Offline, revision-bound source evidence; independent of the seven Vitals.""" -CONTRACT = "devostasis.code-analysis.v1" -POLICY = "devostasis.code-policy.v1" -ENGINE = "devostasis.code-engine.v1" -PACKET = "devostasis.code-packet.v1" -COMPARISON = "devostasis.code-comparison.v1" +LEGACY_CONTRACT = "devostasis.code-analysis.v1" +LEGACY_POLICY = "devostasis.code-policy.v1" +LEGACY_ENGINE = "devostasis.code-engine.v1" +LEGACY_PACKET = "devostasis.code-packet.v1" +LEGACY_COMPARISON = "devostasis.code-comparison.v1" +CONTRACT = "devostasis.code-analysis.v2" +POLICY = "devostasis.code-policy.v2" +ENGINE = "devostasis.code-engine.v2" +PACKET = "devostasis.code-packet.v2" +COMPARISON = "devostasis.code-comparison.v2" +HISTORY_PROOF = "devostasis.git-history-proof.v1" diff --git a/src/devostasis/codeanalysis/bundle.py b/src/devostasis/codeanalysis/bundle.py index a291775..3ac0d6c 100644 --- a/src/devostasis/codeanalysis/bundle.py +++ b/src/devostasis/codeanalysis/bundle.py @@ -15,7 +15,7 @@ from ..workscope.bundle import locked from ..workscope.model import fields, instant, number, path, require, revision, strings, timestamp from ..workscope.operations import no_links -from . import CONTRACT, ENGINE +from . import CONTRACT, ENGINE, LEGACY_CONTRACT, LEGACY_ENGINE, POLICY from .engine import RULES, evaluate, policy from .git import object_digest, source_selection @@ -33,17 +33,22 @@ def decode(value, cap): def admit(raw, config): policy(config) - fields(raw, ("subject", "observed_at", "entries", "sources", "history", "proof")) + proven = config["contract"] == POLICY + fields(raw, ("subject", "observed_at", "entries", "sources", "history", "proof", *(["history_proof"] if proven else []))) subject = raw["subject"] - fields(subject, ("kind", "object_format", "roots", "repository_id", "revision", "tree")) + fields(subject, ("kind", "object_format", "roots", "repository_id", "revision", "tree", *(["roots_complete"] if proven else []))) require(subject["kind"] == "LOCAL_GIT" and subject["object_format"] in ("sha1", "sha256"), "unsupported local source identity") width = 40 if subject["object_format"] == "sha1" else 64 for ref in (subject["revision"], subject["tree"]): revision(ref); require(len(ref) == width, "Git object format mismatch") strings(subject["roots"], "root commits") - require(0 < len(subject["roots"]) <= 256 and subject["roots"] == sorted(subject["roots"]), "noncanonical root identity") + require((proven or subject["roots"]) and len(subject["roots"]) <= (10000 if proven else 256) + and subject["roots"] == sorted(subject["roots"]), "noncanonical root identity") for ref in subject["roots"]: revision(ref); require(len(ref) == width, "root format mismatch") - require(subject["repository_id"] == digest({"object_format": subject["object_format"], "roots": subject["roots"]}), "repository identity mismatch") + if proven: + require(type(subject["roots_complete"]) is bool, "root completeness must be boolean") + else: + require(subject["repository_id"] == digest({"object_format": subject["object_format"], "roots": subject["roots"]}), "repository identity mismatch") instant(raw["observed_at"]) fields(raw["proof"], ("commit", "trees")) commit = decode(raw["proof"]["commit"], 1024 * 1024) @@ -108,6 +113,10 @@ def admit(raw, config): require(entry["reason"] in ("UNSAFE_PATH", "SOURCE_LINK_OR_SUBMODULE", "FILE_BYTE_CAP", "FILE_COUNT_CAP", "TOTAL_BYTE_CAP"), "unavailable source reason unknown") require(seen == sorted(set(seen)) and set(raw["sources"]) == admitted, "duplicate, unordered or foreign source") require(len(admitted) <= config["budget"]["max_files"] and source_total <= config["budget"]["max_total_bytes"], "source budget exceeded") + if proven: + from .history_proof import admit_proven + admit_proven(raw, config) + return raw history = raw["history"] fields(history, ("status", "reasons", "start", "end", "commits")); strings(history["reasons"], "history reasons") require(instant(history["end"]) == instant(raw["observed_at"]) and instant(history["start"]) == instant(history["end"]) - timedelta(days=config["history"]["days"]), "history window mismatch") @@ -138,7 +147,7 @@ def escape(value): def sarif(analysis, *, encode_paths=True): return {"version": "2.1.0", "$schema": "https://json.schemastore.org/sarif-2.1.0.json", "runs": [{ - "tool": {"driver": {"name": "Devostasis Code Analysis", "version": ENGINE, + "tool": {"driver": {"name": "Devostasis Code Analysis", "version": analysis["engine"], "rules": [{"id": r, "shortDescription": {"text": m}} for r, m in sorted(RULES.items())]}}, "versionControlProvenance": [{"revisionId": analysis["subject"]["revision"]}], "properties": {"sourceCoverage": analysis["coverage"], "historyCoverage": analysis["history"]["status"], "authority": analysis["authority"]}, @@ -177,7 +186,7 @@ def build(raw, config): admit(raw, config); analysis = evaluate(raw, config) content = {"inputs.json": canonical_bytes(raw), "policy.json": canonical_bytes(config), "analysis.json": canonical_bytes(analysis), "findings.sarif": canonical_bytes(sarif(analysis)), "report.md": render(analysis)} - manifest = {"contract": CONTRACT, "engine": ENGINE, "subject": deepcopy(raw["subject"]), + manifest = {"contract": CONTRACT if "history_proof" in raw else LEGACY_CONTRACT, "engine": analysis["engine"], "subject": deepcopy(raw["subject"]), "members": {name: digest_bytes(data) for name, data in sorted(content.items())}} manifest["bundle_id"] = digest(manifest); content["manifest.json"] = canonical_bytes(manifest) require(sum(map(len, content.values())) <= MAX_BUNDLE_BYTES, "analysis bundle byte cap") @@ -190,7 +199,7 @@ def verify_content(content): require(all(canonical_bytes(value) == content[name] for name, value in decoded.items()), "analysis member is not canonical") manifest = decoded["manifest.json"] fields(manifest, ("contract", "engine", "subject", "members", "bundle_id")) - require(manifest["contract"] == CONTRACT and manifest["engine"] == ENGINE, "unsupported analysis lineage") + require((manifest["contract"], manifest["engine"]) in ((CONTRACT, ENGINE), (LEGACY_CONTRACT, LEGACY_ENGINE)), "unsupported analysis lineage") expected, replay = build(decoded["inputs.json"], decoded["policy.json"]) require(expected == manifest and replay == content, "analysis bundle does not replay") return decoded @@ -215,9 +224,15 @@ def verify(root): def proof_scope(decoded): """Operational verification limits, outside the immutable v1 artifact bytes.""" omitted = any(e["kind"] == "blob" and e["status"] != "ADMITTED" for e in decoded["inputs.json"]["entries"]) - return {"source_tree": "VERIFIED", "admitted_blobs": "VERIFIED", + result = {"source_tree": "VERIFIED", "admitted_blobs": "VERIFIED", "omitted_blob_sizes": "UNPROVEN" if omitted else "NOT_APPLICABLE", "history_ancestry": "UNPROVEN"} + raw = decoded["inputs.json"] + if "history_proof" in raw: + result["history_ancestry"] = "VERIFIED" if raw["subject"]["roots_complete"] else "PARTIAL" + result["history_counts"] = "NOT_REQUESTED" if raw["history"]["status"] == "NOT_REQUESTED" else ( + "VERIFIED" if raw["history"]["status"] == "COMPLETE" else "PARTIAL") + return result def publish(root, content): diff --git a/src/devostasis/codeanalysis/consumer.py b/src/devostasis/codeanalysis/consumer.py index 07ec5d4..898251c 100644 --- a/src/devostasis/codeanalysis/consumer.py +++ b/src/devostasis/codeanalysis/consumer.py @@ -5,16 +5,20 @@ from ..canonical import canonical_bytes, digest from ..workscope.model import fields, instant, number, path, require, strings -from . import COMPARISON, ENGINE, PACKET +from . import COMPARISON, PACKET, CONTRACT, LEGACY_COMPARISON, LEGACY_PACKET from .bundle import decode, sarif def compare(before, after): a, b = before["analysis.json"], after["analysis.json"] reasons = [] + if a["engine"] != b["engine"]: reasons.append("ANALYSIS_LINEAGE_CHANGED") + if (a["subject"].get("roots_complete") is False or b["subject"].get("roots_complete") is False): + reasons.append("LOCAL_REPOSITORY_IDENTITY_UNPROVEN") if a["subject"]["repository_id"] != b["subject"]["repository_id"]: reasons.append("LOCAL_REPOSITORY_IDENTITY_CHANGED") if before["policy.json"] != after["policy.json"]: reasons.append("ANALYSIS_POLICY_CHANGED") - result = {"contract": COMPARISON, "before": before["manifest.json"]["bundle_id"], "after": after["manifest.json"]["bundle_id"], + contract = COMPARISON if any(v["manifest.json"]["contract"] == CONTRACT for v in (before, after)) else LEGACY_COMPARISON + result = {"contract": contract, "before": before["manifest.json"]["bundle_id"], "after": after["manifest.json"]["bundle_id"], "status": "INCOMPARABLE" if reasons else "COMPARABLE", "reasons": reasons, "findings": [], "files": [], "authority": "STATIC_FINDING_TRANSITIONS; no health, productivity, improvement or acceptance judgement"} if reasons: return result @@ -50,7 +54,7 @@ def packet(decoded, paths, max_files=20, max_bytes=1024 * 1024): data = decode(raw["sources"][filename], max_bytes); total += len(data) require(total <= max_bytes, "source packet byte budget") files.append({"path": filename, "object_id": entries[filename]["object_id"], "size": len(data), "base64": raw["sources"][filename]}) - value = {"contract": PACKET, "analysis_bundle_id": decoded["manifest.json"]["bundle_id"], "subject": deepcopy(raw["subject"]), + value = {"contract": PACKET if decoded["manifest.json"]["contract"] == CONTRACT else LEGACY_PACKET, "analysis_bundle_id": decoded["manifest.json"]["bundle_id"], "subject": deepcopy(raw["subject"]), "budget": {"max_files": max_files, "max_bytes": max_bytes}, "files": files, "total_bytes": total, "authority": "PINNED_LOCAL_SOURCE_ONLY; no live freshness, independent acceptance or execution authority"} value["packet_id"] = digest(value) @@ -82,7 +86,7 @@ def work_evidence(decoded, inventory, at=None): # SARIF artifact uses encoded URI references for external consumers. report = canonical_bytes(sarif(analysis, encode_paths=False)) value = empty_evidence() - entry = source("sarif.v1", ENGINE, "1", deepcopy(inventory["subject"]), observed, report) + entry = source("sarif.v1", analysis["engine"], "1", deepcopy(inventory["subject"]), observed, report) if analysis["coverage"] != "COMPLETE": entry.update(status="PARTIAL", reason="CODE_SOURCE_COVERAGE_" + analysis["coverage"]) value["sources"].append(entry) diff --git a/src/devostasis/codeanalysis/engine.py b/src/devostasis/codeanalysis/engine.py index 11ac2b4..dcfb2aa 100644 --- a/src/devostasis/codeanalysis/engine.py +++ b/src/devostasis/codeanalysis/engine.py @@ -9,24 +9,28 @@ from ..canonical import digest from ..workscope.model import fields, number, path, require, strings -from . import ENGINE, POLICY +from . import ENGINE, LEGACY_ENGINE, LEGACY_POLICY, POLICY RULES = {"DS-PY-SYNTAX": "Python syntax cannot be parsed", "DS-PY-COMPLEXITY": "Function decision count exceeds policy", "DS-PY-FUNCTION-SIZE": "Function line span exceeds policy", "DS-PY-FILE-SIZE": "File line count exceeds policy", "DS-PY-BARE-EXCEPT": "Bare except also catches process control exceptions", "DS-PY-IMPORT-CYCLE": "Static import cycle"} -def default_policy(): - return {"contract": POLICY, "version": "default-1", "grammar": "PYTHON_3_12", "include": [], +def default_policy(*, legacy=False): + value = {"contract": LEGACY_POLICY if legacy else POLICY, "version": "default-1" if legacy else "default-2", "grammar": "PYTHON_3_12", "include": [], "exclude": [".venv", "build", "dist", "vendor", "node_modules"], "source_roots": [".", "src"], "budget": {"max_files": 2000, "max_file_bytes": 1024 * 1024, "max_total_bytes": 16 * 1024 * 1024, "seconds": 120}, "limits": {"complexity": 15, "function_lines": 80, "file_lines": 600}, "history": {"enabled": True, "days": 28, "max_commits": 500}} + if not legacy: + value["history"].update(max_ancestors=2000, max_objects=10000, max_proof_bytes=32 * 1024 * 1024, + max_blob_bytes=4 * 1024 * 1024, max_diff_work=2_000_000, max_diff_lines=100000) + return value def policy(value): fields(value, ("contract", "version", "grammar", "include", "exclude", "source_roots", "budget", "limits", "history")) - require(value["contract"] == POLICY and value["grammar"] == "PYTHON_3_12", "unsupported code policy") + require(value["contract"] in (POLICY, LEGACY_POLICY) and value["grammar"] == "PYTHON_3_12", "unsupported code policy") from ..workscope.model import text text(value["version"]) strings(value["include"], "included roots", paths=True); strings(value["exclude"], "excluded roots", paths=True) @@ -40,7 +44,14 @@ def policy(value): require(value["budget"]["max_file_bytes"] <= value["budget"]["max_total_bytes"], "per-file budget exceeds total") fields(value["limits"], ("complexity", "function_lines", "file_lines")) for v in value["limits"].values(): number(v, 1, 100000) - fields(value["history"], ("enabled", "days", "max_commits")) + history_keys = ("enabled", "days", "max_commits") + if value["contract"] == POLICY: + history_keys += ("max_ancestors", "max_objects", "max_proof_bytes", "max_blob_bytes", "max_diff_work", "max_diff_lines") + for key, low, high in (("max_ancestors", 1, 10000), ("max_objects", 1, 50000), + ("max_proof_bytes", 1024, 64 * 1024 * 1024), ("max_blob_bytes", 1, 4 * 1024 * 1024), + ("max_diff_work", 1, 10_000_000), ("max_diff_lines", 1, 200000)): + number(value["history"].get(key), low, high) + fields(value["history"], history_keys) require(type(value["history"]["enabled"]) is bool, "explicit history enablement required") number(value["history"]["days"], 1, 3650); number(value["history"]["max_commits"], 1, 10000) return value @@ -189,21 +200,27 @@ def finding(rule, filename, symbol, line, end, evidence, related=()): for component in components: finding("DS-PY-IMPORT-CYCLE", component[0], "cycle:" + digest(component)[7:], 1, 1, {"paths": component}, component) findings.sort(key=lambda f: (f["path"], f["line"], f["rule"], f["symbol"])) - history = raw["history"]; changes = defaultdict(lambda: {"revisions": set(), "added": 0, "deleted": 0, "binary": 0}) + proven = "history_proof" in raw + history = raw["history"]; changes = defaultdict(lambda: {"revisions": set(), "added": 0, "deleted": 0, "binary": 0, "unproven": 0}) for commit in history["commits"]: for change in commit["changes"]: if change["path"] not in entries or entries[change["path"]]["status"] == "OUT_OF_SCOPE": continue cell = changes[change["path"]]; cell["revisions"].add(commit["revision"]) - if change["added"] is None: cell["binary"] += 1 + if proven and change["count_status"] == "UNAVAILABLE": cell["unproven"] += 1 + elif change["added"] is None: cell["binary"] += 1 else: cell["added"] += change["added"]; cell["deleted"] += change["deleted"] hotspots = [{"path": p, "revisions": len(c["revisions"]), "added": c["added"], "deleted": c["deleted"], "binary_changes": c["binary"], "coverage": history["status"]} for p, c in changes.items()] + if proven: + for hotspot in hotspots: + hotspot["unproven_changes"] = changes[hotspot["path"]]["unproven"] + hotspot["count_status"] = "PARTIAL" if hotspot["unproven_changes"] else "VERIFIED" hotspots.sort(key=lambda h: (-h["revisions"], -(h["added"] + h["deleted"]), h["path"])) unknown = any(r["status"] in ("UNAVAILABLE", "PARSE_ERROR") for r in output) applicable = any(e["status"] == "ADMITTED" or (e["path"] and e["path"].endswith(".py") and e["status"] == "UNAVAILABLE") for e in raw["entries"]) - return {"engine": ENGINE, "subject": deepcopy(raw["subject"]), "observed_at": raw["observed_at"], + return {"engine": ENGINE if proven else LEGACY_ENGINE, "subject": deepcopy(raw["subject"]), "observed_at": raw["observed_at"], "coverage": "PARTIAL" if unknown else "COMPLETE" if applicable else "NOT_APPLICABLE", "files": output, "findings": findings, "imports": imports, "import_cycles": components, "hotspots": hotspots, "history": {k: deepcopy(v) for k, v in history.items() if k != "commits"}, diff --git a/src/devostasis/codeanalysis/git.py b/src/devostasis/codeanalysis/git.py index 1dcb409..6953eb2 100644 --- a/src/devostasis/codeanalysis/git.py +++ b/src/devostasis/codeanalysis/git.py @@ -115,13 +115,24 @@ def resolve(self, ref): def collect(directory, ref, config, at=None): - git = Git(directory, config["budget"]["seconds"]) + from . import LEGACY_POLICY, POLICY + from .engine import policy + policy(config) + if config["contract"] == POLICY: + from .history_proof import collect_proven + return collect_proven(directory, ref, config, at) + require(config["contract"] == LEGACY_POLICY, "unsupported collection policy") + return _collect_v1(directory, ref, config, at) + + +def _collect_v1(directory, ref, config, at=None, *, _git=None, _source_only=False): + git = _git or Git(directory, config["budget"]["seconds"]) commit = git.resolve(ref) tree = git.run(["rev-parse", commit + "^{tree}"]).strip().decode("ascii") commit_epoch = int(git.run(["show", "-s", "--format=%ct", commit]).strip()) observed = timestamp(at or datetime.fromtimestamp(commit_epoch, timezone.utc).isoformat()) # A local identity names the complete root set, never an inferred forge id. - roots_raw = git.run(["rev-list", "--max-parents=0", commit], cap=65536) + roots_raw = (commit + "\n").encode("ascii") if _source_only else git.run(["rev-list", "--max-parents=0", commit], cap=65536) roots = sorted(set(roots_raw.decode("ascii").splitlines())) require(0 < len(roots) <= 256 and all(re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", r) for r in roots), "local root identity unavailable") rows = git.run(["ls-tree", "-r", "-l", "-z", "--full-tree", tree]) @@ -175,7 +186,7 @@ def collect(directory, ref, config, at=None): start = instant(observed) - timedelta(days=config["history"]["days"]) history = {"status": "NOT_REQUESTED", "reasons": [], "start": timestamp(start.isoformat()), "end": observed, "commits": []} - if config["history"]["enabled"]: + if config["history"]["enabled"] and not _source_only: limit = config["history"]["max_commits"] try: if git.run(["rev-parse", "--is-shallow-repository"]).strip() == b"true": diff --git a/src/devostasis/codeanalysis/history_proof.py b/src/devostasis/codeanalysis/history_proof.py new file mode 100644 index 0000000..f5f0445 --- /dev/null +++ b/src/devostasis/codeanalysis/history_proof.py @@ -0,0 +1,331 @@ +"""Bounded Git object proofs and pure offline ancestry/line-edit admission. + +No Git executable, diff driver, checkout, clock or repository is used by replay. +LF-delimited byte lines use a bounded shortest edit distance, independent of +Git configuration and rename/diff heuristics. Missing proof is explicit. +""" + +import base64 +from datetime import datetime, timedelta, timezone + +from ..canonical import digest +from ..workscope.model import fields, instant, path, require, revision, timestamp +from . import HISTORY_PROOF +from .git import Git, _collect_v1, object_digest + +KINDS = ("commit", "tree", "blob") +OMISSIONS = {"OBJECT_UNAVAILABLE", "OBJECT_BYTE_CAP", "OBJECT_COUNT_CAP", "PROOF_BYTE_CAP", "TIME_CAP"} +ALGORITHM = "MYERS_LF_BYTE_LINES_V1" + + +def encoded(data): + return base64.b64encode(data).decode("ascii") + + +def decoded(value, cap): + require(isinstance(value, str) and len(value) <= (cap + 2) // 3 * 4, "history object encoding cap") + data = base64.b64decode(value, validate=True) + require(len(data) <= cap and encoded(data) == value, "noncanonical history object") + return data + + +def object_ref(value, width): + revision(value) + require(len(value) == width, "history object format mismatch") + return value + + +def commit_headers(data, width): + header, separator, _ = data.partition(b"\n\n") + require(separator, "commit header terminator missing") + lines = header.split(b"\n") + trees = [line[5:].decode("ascii") for line in lines if line.startswith(b"tree ")] + parents = [line[7:].decode("ascii") for line in lines if line.startswith(b"parent ")] + committers = [line[10:] for line in lines if line.startswith(b"committer ")] + require(len(trees) == 1 and len(committers) == 1 and len(parents) <= 10000 + and len(parents) == len(set(parents)), "commit tree/parent/committer shape") + for ref in trees + parents: + object_ref(ref, width) + identity, epoch, zone = committers[0].rsplit(b" ", 2) + require(identity.endswith(b">") and len(zone) == 5 and zone[:1] in (b"+", b"-") + and zone[1:].isdigit(), "commit committer syntax") + require(epoch.lstrip(b"-").isdigit(), "commit epoch syntax") + when = timestamp(datetime.fromtimestamp(int(epoch), timezone.utc).isoformat()) + return {"tree": trees[0], "parents": parents, "committed_at": when} + + +def seed_objects(raw): + fmt = raw["subject"]["object_format"] + objects = {kind: {} for kind in KINDS} + objects["commit"][raw["subject"]["revision"]] = decoded(raw["proof"]["commit"], 1024 * 1024) + objects["tree"] = {oid: decoded(value, 8 * 1024 * 1024) for oid, value in raw["proof"]["trees"].items()} + entries = {e["path"]: e for e in raw["entries"] if e["path"] is not None} + for name, value in raw["sources"].items(): + objects["blob"][entries[name]["object_id"]] = base64.b64decode(value, validate=True) + for kind, rows in objects.items(): + require(all(object_digest(data, fmt, kind) == oid for oid, data in rows.items()), "history seed object mismatch") + return objects + + +class Objects: + """Same bounded lookup interface for acquisition and independent replay.""" + + def __init__(self, raw, config, proof=None, git=None): + self.format = raw["subject"]["object_format"] + self.width = 40 if self.format == "sha1" else 64 + self.limits = config["history"] + self.seed = seed_objects(raw) + self.objects = {kind: dict(rows) for kind, rows in self.seed.items()} + self.extra = {kind: {} for kind in KINDS} + self.omitted, self.used = {}, set() + self.bytes = self.count = 0 + self.git = git + if proof is not None: + fields(proof, ("contract", "commits", "trees", "blobs", "omitted", "frontier")) + require(proof["contract"] == HISTORY_PROOF, "unsupported history proof lineage") + for kind in KINDS: + rows = proof[kind + "s"] + require(isinstance(rows, dict) and len(rows) <= self.limits["max_objects"], "history object count cap") + for oid, value in rows.items(): + object_ref(oid, self.width) + require(oid not in self.objects[kind], "duplicate source/history object") + cap = self.limits["max_blob_bytes"] if kind == "blob" else 1024 * 1024 if kind == "commit" else 8 * 1024 * 1024 + data = decoded(value, cap) + require(object_digest(data, self.format, kind) == oid, "historic object hash mismatch") + self.objects[kind][oid] = self.extra[kind][oid] = data + self.bytes += len(data); self.count += 1 + require(self.count <= self.limits["max_objects"] and self.bytes <= self.limits["max_proof_bytes"], "history proof budget exceeded") + require(self.count <= self.limits["max_objects"] and self.bytes <= self.limits["max_proof_bytes"], "history proof budget exceeded") + require(isinstance(proof["omitted"], dict) and len(proof["omitted"]) <= 100000, "history omission cap") + for oid, reason in proof["omitted"].items(): + object_ref(oid, self.width) + require(reason in OMISSIONS and not any(oid in rows for rows in self.objects.values()), "history omission mismatch") + self.omitted = dict(proof["omitted"]) + + def prefetch(self, kind, refs): + if self.git is None: + return + refs = [oid for oid in sorted(set(refs)) if oid not in self.objects[kind] and oid not in self.omitted] + if not refs: + return + try: + metadata = self.git.run(["cat-file", "--batch-check"], data="".join(r + "\n" for r in refs).encode("ascii"), cap=len(refs) * 160) + lines = metadata.splitlines() + require(len(lines) == len(refs), "historic object header count") + selected, size_total = [], 0 + for oid, line in zip(refs, lines): + parts = line.split() + if parts == [oid.encode("ascii"), b"missing"]: + self.omitted[oid] = "OBJECT_UNAVAILABLE"; continue + require(len(parts) == 3 and parts[0].decode("ascii") == oid and parts[1].decode("ascii") == kind, + "historic object kind mismatch") + size = int(parts[2]) + cap = self.limits["max_blob_bytes"] if kind == "blob" else 1024 * 1024 if kind == "commit" else 8 * 1024 * 1024 + if size > cap: + self.omitted[oid] = "OBJECT_BYTE_CAP" + elif self.count + len(selected) >= self.limits["max_objects"]: + self.omitted[oid] = "OBJECT_COUNT_CAP" + elif self.bytes + size_total + size > self.limits["max_proof_bytes"]: + self.omitted[oid] = "PROOF_BYTE_CAP" + else: + require(size >= 0, "historic object negative size") + selected.append((oid, size)); size_total += size + if selected: + data = self.git.run(["cat-file", "--batch"], data="".join(r + "\n" for r, _ in selected).encode("ascii"), cap=size_total + len(selected) * 160) + offset = 0 + for oid, size in selected: + end = data.index(b"\n", offset) + require(data[offset:end] == f"{oid} {kind} {size}".encode("ascii"), "historic object framing mismatch") + offset = end + 1; value = data[offset:offset + size]; offset += size + require(data[offset:offset + 1] == b"\n" and object_digest(value, self.format, kind) == oid, "historic object bytes mismatch") + offset += 1; self.objects[kind][oid] = self.extra[kind][oid] = value + self.bytes += size; self.count += 1 + require(offset == len(data), "historic object trailing bytes") + except (ValueError, OSError) as exc: + reason = "TIME_CAP" if "TIME_CAP" in str(exc) else "OBJECT_BYTE_CAP" if "BYTE_CAP" in str(exc) else "OBJECT_UNAVAILABLE" + for oid in refs: + if oid not in self.objects[kind]: self.omitted[oid] = reason + + def get(self, kind, oid): + object_ref(oid, self.width) + self.used.add((kind, oid)) + require(len(self.used) <= 100000, "history reference work cap") + self.prefetch(kind, [oid]) + if oid in self.objects[kind]: + return self.objects[kind][oid] + require(oid in self.omitted, "required history object is neither proved nor explicitly unavailable") + return None + + def finish(self): + require(all((kind, oid) in self.used for kind in KINDS for oid in self.extra[kind]), "unreachable or unused history object") + require(set(self.omitted) <= {oid for _, oid in self.used}, "unused history omission") + + def proof(self, frontier): + return {"contract": HISTORY_PROOF, **{kind + "s": {oid: encoded(data) for oid, data in sorted(rows.items())} + for kind, rows in self.extra.items()}, "omitted": dict(sorted(self.omitted.items())), "frontier": sorted(frontier)} + + +def graph(objects, head): + pending, attempted, nodes, absent = {head}, set(), {}, set() + while pending and len(attempted) < objects.limits["max_ancestors"]: + batch = sorted(pending)[:min(64, objects.limits["max_ancestors"] - len(attempted))] + objects.prefetch("commit", batch) + for oid in batch: + pending.remove(oid); attempted.add(oid) + data = objects.get("commit", oid) + if data is None: + absent.add(oid); continue + node = commit_headers(data, objects.width); nodes[oid] = node + pending.update(p for p in node["parents"] if p not in attempted) + require(len(pending) + len(absent) <= 10000, "history frontier cap") + frontier = pending | absent + roots = sorted(oid for oid, node in nodes.items() if not node["parents"]) + require(frontier or roots, "complete ancestry has no root") + return nodes, frontier, roots + + +def tree(objects, root, work): + stack, entries, complete = [(root, b"", frozenset())], {}, True + while stack: + oid, prefix, parents = stack.pop() + require(oid not in parents, "historic tree cycle") + data = objects.get("tree", oid) + if data is None: + complete = False; continue + offset = 0 + while offset < len(data): + work[0] += 1 + if work[0] > objects.limits["max_diff_work"]: + return entries, False + space = data.find(b" ", offset); end = data.find(b"\0", space) + require(space > offset and end > space + 1 and end + 1 + objects.width // 2 <= len(data), "historic tree entry malformed") + mode, name = data[offset:space], data[space + 1:end] + require(b"/" not in name and name not in (b".", b".."), "historic tree name malformed") + child = data[end + 1:end + 1 + objects.width // 2].hex(); offset = end + 1 + objects.width // 2 + filename = prefix + name + if mode == b"40000": + stack.append((child, filename + b"/", parents | {oid})) + else: + require(mode in (b"100644", b"100755", b"120000", b"160000") and filename not in entries, "historic tree mode/duplicate") + entries[filename] = (mode, child) + require(len(entries) <= 100000 and len(stack) <= 10000, "historic tree expansion cap") + return entries, complete + + +def line_edits(before, after, limits, work): + """Shortest insertion/deletion counts over exact LF-terminated byte lines.""" + if b"\0" in before or b"\0" in after: + return None, None, "BINARY", None + line_count = lambda value: value.count(b"\n") + int(bool(value) and not value.endswith(b"\n")) + if line_count(before) + line_count(after) > limits["max_diff_lines"]: + return None, None, "UNAVAILABLE", "DIFF_LINE_CAP" + def lines(value): + parts = value.split(b"\n") + return [p + b"\n" for p in parts[:-1]] + ([parts[-1]] if parts[-1] else []) + a, b = lines(before), lines(after) + n, m = len(a), len(b) + if not n or not m: + return m, n, "VERIFIED", None + frontier = {1: 0} + for distance in range(n + m + 1): + for diagonal in range(-distance, distance + 1, 2): + work[0] += 1 + if work[0] > limits["max_diff_work"]: + return None, None, "UNAVAILABLE", "DIFF_WORK_CAP" + if diagonal == -distance or (diagonal != distance and frontier.get(diagonal - 1, -1) < frontier.get(diagonal + 1, -1)): + x = frontier.get(diagonal + 1, 0) + else: + x = frontier[diagonal - 1] + 1 + y = x - diagonal + while x < n and y < m and a[x] == b[y]: + work[0] += 1 + if work[0] > limits["max_diff_work"]: + return None, None, "UNAVAILABLE", "DIFF_WORK_CAP" + x += 1; y += 1 + frontier[diagonal] = x + if x >= n and y >= m: + return (distance + m - n) // 2, (distance + n - m) // 2, "VERIFIED", None + raise ValueError("line edit traversal failed") + + +def derive_history(raw, config, objects): + head = raw["subject"]["revision"] + nodes, frontier, roots = graph(objects, head) + complete = not frontier + identity = {"object_format": objects.format, "roots": roots} + if not complete: identity["unproven_ancestry_anchor"] = head + subject = {**raw["subject"], "roots": roots, "roots_complete": complete, "repository_id": digest(identity)} + end = instant(raw["observed_at"]); start = end - timedelta(days=config["history"]["days"]) + history = {"status": "NOT_REQUESTED", "reasons": [], "start": timestamp(start.isoformat()), "end": timestamp(end.isoformat()), + "commits": [], "ancestry_status": "COMPLETE" if complete else "PARTIAL", "count_algorithm": ALGORITHM} + if not config["history"]["enabled"]: + return subject, history, frontier + reasons = set() + if frontier: reasons.add("HISTORY_GRAPH_INCOMPLETE") + selected = sorted(((oid, row) for oid, row in nodes.items() if len(row["parents"]) <= 1 + and start <= instant(row["committed_at"]) <= end), key=lambda pair: (pair[1]["committed_at"], pair[0]), reverse=True) + if len(selected) > config["history"]["max_commits"]: reasons.add("HISTORY_COMMIT_CAP") + selected = sorted(selected[:config["history"]["max_commits"]], key=lambda pair: (pair[1]["committed_at"], pair[0])) + tree_cache, work = {}, [0] + def inventory(root): + if root not in tree_cache: tree_cache[root] = tree(objects, root, work) + return tree_cache[root] + for oid, row in selected: + current, current_complete = inventory(row["tree"]) + parent = nodes.get(row["parents"][0]) if row["parents"] else None + previous, previous_complete = inventory(parent["tree"]) if parent else ({}, not row["parents"]) + changes, changes_complete = [], current_complete and previous_complete + for filename in sorted(set(current) | set(previous)): + old, new = previous.get(filename), current.get(filename) + if old == new: continue + try: name = path(filename.decode("utf-8")) + except (ValueError, UnicodeError): + changes_complete = False; reasons.add("HISTORY_PATH_UNAVAILABLE"); continue + included = not config["include"] or any(name == p or name.startswith(p + "/") for p in config["include"]) + excluded = any(name == p or name.startswith(p + "/") for p in config["exclude"]) + if not included or excluded: + changes.append({"path": name, "added": None, "deleted": None, + "count_status": "OUT_OF_SCOPE", "reason": "POLICY_EXCLUSION"}) + continue + sides = [] + for entry in (old, new): + if entry is None: sides.append(b"") + elif entry[0] == b"160000": sides.append(None) + else: sides.append(objects.get("blob", entry[1])) + if (old is None and not previous_complete) or (new is None and not current_complete): + added, deleted, status, reason = None, None, "UNAVAILABLE", "HISTORIC_TREE_UNAVAILABLE" + elif old and new and old[1] == new[1]: + added, deleted, status, reason = 0, 0, "VERIFIED", None + elif any(value is None for value in sides): + added, deleted, status, reason = None, None, "UNAVAILABLE", "HISTORIC_BLOB_UNAVAILABLE" + else: + added, deleted, status, reason = line_edits(*sides, config["history"], work) + if status == "UNAVAILABLE": + changes_complete = False; reasons.add(reason) + changes.append({"path": name, "added": added, "deleted": deleted, "count_status": status, "reason": reason}) + if not changes_complete: reasons.add("HISTORY_CHANGE_PROOF_INCOMPLETE") + history["commits"].append({"revision": oid, "committed_at": row["committed_at"], "changes": changes, + "changes_complete": changes_complete}) + history["reasons"] = sorted(reasons) + history["status"] = "PARTIAL" if reasons else "COMPLETE" + return subject, history, frontier + + +def collect_proven(directory, ref, config, at=None): + git = Git(directory, config["budget"]["seconds"]) + raw = _collect_v1(directory, ref, config, at, _git=git, _source_only=True) + objects = Objects(raw, config, git=git) + subject, history, frontier = derive_history(raw, config, objects) + raw.update(subject=subject, history=history, history_proof=objects.proof(frontier)) + objects.finish() + return raw + + +def admit_proven(raw, config): + objects = Objects(raw, config, proof=raw["history_proof"]) + subject, history, frontier = derive_history(raw, config, objects) + require(raw["subject"] == subject, "ancestry/root identity proof mismatch") + require(raw["history"] == history, "historic counts/completeness do not match object proof") + require(raw["history_proof"]["frontier"] == sorted(frontier), "ancestry frontier mismatch") + objects.finish() + return objects diff --git a/tests/test_code_analysis.py b/tests/test_code_analysis.py index a9e0bb2..f7f8838 100644 --- a/tests/test_code_analysis.py +++ b/tests/test_code_analysis.py @@ -263,7 +263,7 @@ def test_code_shallow_history_retains_explicit_partial_coverage(tmp_path): git(tmp_path, "clone", "--depth=1", repo.as_uri(), str(clone)) result = analyze(clone, at=NEXT)[4]["analysis.json"] assert result["history"]["status"] == "PARTIAL" - assert "SHALLOW_HISTORY" in result["history"]["reasons"] + assert "HISTORY_GRAPH_INCOMPLETE" in result["history"]["reasons"] def test_code_sha256_repository_proofs_replay_with_explicit_object_format(tmp_path): diff --git a/tests/test_code_history_proof.py b/tests/test_code_history_proof.py new file mode 100644 index 0000000..2ab8164 --- /dev/null +++ b/tests/test_code_history_proof.py @@ -0,0 +1,141 @@ +"""Independent object-proof admission rather than trust in collector claims.""" + +from copy import deepcopy +import itertools + +import pytest + +from devostasis.codeanalysis.bundle import admit, build, proof_scope, verify_content +from devostasis.codeanalysis.consumer import compare, packet, verify_packet +from devostasis.codeanalysis.engine import default_policy +from devostasis.codeanalysis.history_proof import line_edits +from test_code_analysis import NEXT, analyze, commit, git, repository + + +@pytest.mark.parametrize("claim", ["root", "repository", "ancestor", "count", "time", "complete", "frontier"]) +def test_rehashed_history_claims_require_authenticated_objects(tmp_path, claim): + repo = repository(tmp_path, {"a.py": "a = 1\n"}) + commit(repo, {"a.py": "a = 2\nb = 3\n"}, NEXT) + raw, config, _, _, _ = analyze(repo, at=NEXT) + raw = deepcopy(raw) + if claim == "root": raw["subject"]["roots"] = ["0" * 40] + if claim == "repository": raw["subject"]["repository_id"] = "sha256:" + "0" * 64 + if claim == "ancestor": raw["history_proof"]["commits"].clear() + if claim == "count": raw["history"]["commits"][-1]["changes"][0]["added"] = 999 + if claim == "time": raw["history"]["commits"][0]["committed_at"] = NEXT + if claim == "complete": raw["history"]["commits"].pop() + if claim == "frontier": raw["history_proof"]["frontier"] = ["0" * 40] + with pytest.raises(ValueError): build(raw, config) + + +def test_offline_replay_has_no_git_repository_or_subprocess_dependency(tmp_path, monkeypatch): + repo = repository(tmp_path, {"a.py": "x = 1\n"}) + commit(repo, {"a.py": "x = 2\n"}, NEXT) + raw, _, _, members, decoded = analyze(repo, at=NEXT) + import subprocess + def forbidden(*args, **kwargs): raise AssertionError("offline verifier invoked subprocess") + monkeypatch.setattr(subprocess, "Popen", forbidden) + assert verify_content(members) == decoded + assert proof_scope(decoded)["history_counts"] == "VERIFIED" + assert len(raw["history"]["commits"]) == 2 + + +def test_graph_walk_does_not_prune_clock_skew_or_merge_parents(tmp_path): + repo = repository(tmp_path, {"a.py": "x = 1\n"}) + root = git(repo, "rev-parse", "HEAD").strip().decode() + git(repo, "checkout", "-b", "side") + side = commit(repo, {"side.py": "s = 1\n"}, NEXT) + git(repo, "checkout", "main") + main = commit(repo, {"main.py": "m = 1\n"}, "2026-08-01T00:00:00Z") + git(repo, "merge", "--no-ff", "side", "-m", "synthetic merge", at=NEXT) + raw, _, _, _, _ = analyze(repo, at=NEXT) + assert raw["subject"]["roots"] == [root] + assert raw["subject"]["roots_complete"] is True + assert {r["revision"] for r in raw["history"]["commits"]} == {root, side} + assert main in raw["history_proof"]["commits"] + + +@pytest.mark.parametrize("limit,value", [("max_ancestors", 1), ("max_objects", 1), ("max_blob_bytes", 1), ("max_diff_work", 1)]) +def test_bounded_proof_never_claims_complete_history(tmp_path, limit, value): + repo = repository(tmp_path, {"a.py": "x = 1\n"}) + commit(repo, {"a.py": "x = 2\n"}, NEXT) + config = default_policy(); config["history"][limit] = value + raw, _, _, _, decoded = analyze(repo, config, at=NEXT) + assert raw["history"]["status"] == "PARTIAL" + assert proof_scope(decoded)["history_counts"] == "PARTIAL" + forged = deepcopy(raw); forged["history"]["status"] = "COMPLETE"; forged["history"]["reasons"] = [] + with pytest.raises(ValueError): admit(forged, config) + if limit == "max_ancestors": + assert raw["subject"]["roots"] == [] and not raw["subject"]["roots_complete"] + assert compare(decoded, decoded)["status"] == "INCOMPARABLE" + assert all(c["count_status"] == "UNAVAILABLE" for c in raw["history"]["commits"][0]["changes"]) + + +def test_wrongly_typed_historic_objects_fail_closed(tmp_path): + raw, config, _, _, _ = analyze(repository(tmp_path)) + raw["history_proof"]["blobs"][raw["subject"]["revision"]] = raw["proof"]["commit"] + with pytest.raises(ValueError, match="hash mismatch"): admit(raw, config) + + +def test_v1_and_v2_replay_separately_and_do_not_compare_across_lineage(tmp_path): + repo = repository(tmp_path) + old = analyze(repo, default_policy(legacy=True))[4] + new = analyze(repo)[4] + assert old["manifest.json"]["contract"] == "devostasis.code-analysis.v1" + assert new["manifest.json"]["contract"] == "devostasis.code-analysis.v2" + assert proof_scope(old)["history_ancestry"] == "UNPROVEN" + assert proof_scope(new)["history_ancestry"] == "VERIFIED" + assert "ANALYSIS_LINEAGE_CHANGED" in compare(old, new)["reasons"] + for decoded in (old, new): + assert verify_packet(packet(decoded, ["src/pkg/a.py"]), decoded) + + +def test_byte_line_algorithm_matches_independent_lcs_oracle(): + limits = default_policy()["history"] + words = [b"", b"a\n", b"b\n", b"a\nb\n", b"b\na\n", b"a\na\n", b"a"] + for before, after in itertools.product(words, repeat=2): + a, b = before.splitlines(keepends=True), after.splitlines(keepends=True) + table = [[0] * (len(b) + 1) for _ in range(len(a) + 1)] + for i in range(len(a)): + for j in range(len(b)): + table[i + 1][j + 1] = table[i][j] + 1 if a[i] == b[j] else max(table[i][j + 1], table[i + 1][j]) + common = table[-1][-1] + assert line_edits(before, after, limits, [0]) == (len(b) - common, len(a) - common, "VERIFIED", None) + assert line_edits(b"a\0", b"b", limits, [0])[2] == "BINARY" + + +def test_historic_proof_does_not_read_policy_excluded_blob_bytes(tmp_path): + from devostasis.codeanalysis.history_proof import decoded + secret = b"excluded fixture bytes, never part of proof\n" + repo = repository(tmp_path, {"private/secret.txt": secret, "a.py": "x = 1\n"}) + commit(repo, {"private/secret.txt": b"other excluded bytes\n", "a.py": "x = 2\n"}, NEXT) + config = default_policy(); config["exclude"].append("private") + raw, _, _, _, _ = analyze(repo, config, at=NEXT) + assert all(decoded(value, 4 * 1024 * 1024) != secret for value in raw["history_proof"]["blobs"].values()) + assert all(c["count_status"] == "OUT_OF_SCOPE" for row in raw["history"]["commits"] for c in row["changes"] if c["path"].startswith("private/")) + + +def test_valid_but_unreachable_historic_object_is_rejected(tmp_path): + from devostasis.codeanalysis.git import object_digest + from devostasis.codeanalysis.history_proof import encoded + raw, config, _, _, _ = analyze(repository(tmp_path)) + extra = b"unreachable fixture" + raw["history_proof"]["blobs"][object_digest(extra, "sha1")] = encoded(extra) + with pytest.raises(ValueError, match="unused history object"): admit(raw, config) + + +def test_v2_frozen_example_replays_and_has_explicit_schemas(): + import json + from pathlib import Path + from devostasis.codeanalysis.bundle import verify + from devostasis.workscope.bundle import verify as verify_work + root = Path(__file__).resolve().parents[1] + decoded = verify(root / "examples/code-v2/bundle") + assert proof_scope(decoded)["history_ancestry"] == "VERIFIED" + verify_packet(json.loads((root / "examples/code-v2/packet.json").read_text()), decoded) + assert any(i["queue"] == "analyze_code" for i in verify_work(root / "examples/code-v2/work-bundle")["scope.json"]["items"]) + for name, value in (("code-manifest", decoded["manifest.json"]), ("code-policy", decoded["policy.json"]), + ("code-packet", json.loads((root / "examples/code-v2/packet.json").read_text()))): + schema = json.loads((root / f"schemas/{name}-v2.schema.json").read_text()) + assert schema["properties"]["contract"]["const"] == value["contract"] + assert set(value) == set(schema["required"]) == set(schema["properties"]) diff --git a/tests/test_source_admission.py b/tests/test_source_admission.py index 21d8e9e..08d073b 100644 --- a/tests/test_source_admission.py +++ b/tests/test_source_admission.py @@ -36,7 +36,7 @@ def test_valid_caps_and_policy_exclusions_still_replay(tmp_path): def test_omitted_size_claim_does_not_become_independent_blob_proof(tmp_path, capsys): - config = default_policy() + config = default_policy(legacy=True) raw = collect(repository(tmp_path, {"safe.py": "pass\n"}), "HEAD", config, AT) # The tree authenticates the blob id, not this replacement size metadata. changed = deepcopy(raw) From 9717a475896f2ad1410a654f32ddaa11402c33d1 Mon Sep 17 00:00:00 2001 From: Unisphere Dev Date: Wed, 21 Oct 2026 16:16:01 +0300 Subject: [PATCH 30/33] feat(conformance): execute native and stateful assurance scenarios --- .github/workflows/ci.yml | 8 + schemas/conformance-bridge.schema.json | 773 +++++++++++++++ scripts/check_conformance_surface.py | 25 + src/devostasis/adapters/gitlab_ci.py | 19 + src/devostasis/history.py | 30 +- src/devostasis/vitals/integrity.py | 26 + tests/fixtures/conformance-surface.json | 1157 +++++++++++++++++++++++ tests/surface_bridge.py | 287 ++++++ tests/test_conformance_surface.py | 67 ++ 9 files changed, 2390 insertions(+), 2 deletions(-) create mode 100644 schemas/conformance-bridge.schema.json create mode 100644 scripts/check_conformance_surface.py create mode 100644 src/devostasis/adapters/gitlab_ci.py create mode 100644 tests/fixtures/conformance-surface.json create mode 100644 tests/surface_bridge.py create mode 100644 tests/test_conformance_surface.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c94bd7f..4276348 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,8 @@ jobs: run: python -m pytest -q - name: Conformance vectors through the command line run: devostasis vectors --quiet + - name: Versioned conformance operation bridge + run: python scripts/check_conformance_surface.py - name: Example bundle verifies from its own contents run: devostasis verify --bundle examples/sample-bundle - name: Work companion replay and deterministic handoff @@ -56,6 +58,12 @@ jobs: devostasis work replay --bundle examples/code/work-bundle python scripts/make_code_examples.py git diff --exit-code -- examples/code + devostasis code verify --bundle examples/code-v2/bundle + devostasis code verify-packet --bundle examples/code-v2/bundle --packet examples/code-v2/packet.json + devostasis work verify --bundle examples/code-v2/work-bundle + devostasis work replay --bundle examples/code-v2/work-bundle + python scripts/make_code_examples.py --v2 + git diff --exit-code -- examples/code-v2 - name: Determinism check on the example observations run: | a=$(devostasis build --observations examples/observations.json --store /tmp/store-a --planning milestones --debt-label type:refactor --debt-mapping-version example-1 | head -n 1 | awk '{print $2}') diff --git a/schemas/conformance-bridge.schema.json b/schemas/conformance-bridge.schema.json new file mode 100644 index 0000000..7338562 --- /dev/null +++ b/schemas/conformance-bridge.schema.json @@ -0,0 +1,773 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/drevendev/Devostasis/schemas/conformance-bridge.schema.json", + "description": "Source-checkout executable bridge. Runtime additionally checks unique permanent IDs/variants, exact operation lineage, whole T8 matrix, all input fields and every assertion path.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "source", + "cases" + ], + "properties": { + "schema": { + "const": "devostasis.conformance-bridge.v1" + }, + "source": { + "type": "string", + "minLength": 1 + }, + "cases": { + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "case", + "scenario", + "given", + "checks" + ], + "properties": { + "case": { + "type": "string", + "minLength": 1 + }, + "scenario": { + "const": "ci-native.v1" + }, + "given": { + "type": "object", + "additionalProperties": false, + "required": [ + "variants" + ], + "properties": { + "variants": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "provider", + "mapping", + "status", + "conclusion" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "provider": { + "enum": [ + "github", + "gitlab" + ] + }, + "mapping": { + "enum": [ + "devostasis.ci-outcomes.github.v1", + "devostasis.ci-outcomes.gitlab.v1" + ] + }, + "status": { + "type": "string", + "minLength": 1 + }, + "conclusion": { + "type": [ + "string", + "null" + ] + } + } + } + } + } + }, + "checks": { + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "equals" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "equals": {} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "not_equals" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "not_equals": {} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "contains" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "contains": {} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "absent" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "absent": { + "const": true + } + } + } + ] + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "case", + "scenario", + "given", + "checks" + ], + "properties": { + "case": { + "type": "string", + "minLength": 1 + }, + "scenario": { + "const": "independence.v1" + }, + "given": { + "type": "object", + "additionalProperties": false, + "required": [ + "attempts", + "sample_semantics" + ], + "properties": { + "attempts": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "sample_semantics": { + "enum": [ + "RAW_RUNS", + "INDEPENDENT_REVISIONS" + ] + } + } + }, + "checks": { + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "equals" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "equals": {} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "not_equals" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "not_equals": {} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "contains" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "contains": {} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "absent" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "absent": { + "const": true + } + } + } + ] + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "case", + "scenario", + "given", + "checks" + ], + "properties": { + "case": { + "type": "string", + "minLength": 1 + }, + "scenario": { + "const": "multi-vital.v1" + }, + "given": { + "type": "object", + "additionalProperties": false, + "required": [ + "observation_id", + "variants", + "inventories" + ], + "properties": { + "observation_id": { + "type": [ + "string", + "null" + ] + }, + "variants": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "status", + "freshness" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "status": { + "enum": [ + "AVAILABLE", + "PARTIAL", + "FORBIDDEN", + "UNKNOWN", + "ERROR" + ] + }, + "freshness": { + "enum": [ + "FRESH", + "STALE" + ] + } + } + } + }, + "inventories": { + "type": "object", + "additionalProperties": false, + "required": [ + "git.default_branch.commits_28d", + "forge.change_requests.inventory", + "forge.issues.inventory", + "git.nondefault_branches.inventory" + ], + "properties": { + "git.default_branch.commits_28d": { + "type": "array", + "items": { + "type": "object" + } + }, + "forge.change_requests.inventory": { + "type": "array", + "items": { + "type": "object" + } + }, + "forge.issues.inventory": { + "type": "array", + "items": { + "type": "object" + } + }, + "git.nondefault_branches.inventory": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + }, + "checks": { + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "equals" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "equals": {} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "not_equals" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "not_equals": {} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "contains" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "contains": {} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "absent" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "absent": { + "const": true + } + } + } + ] + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "case", + "scenario", + "given", + "checks" + ], + "properties": { + "case": { + "type": "string", + "minLength": 1 + }, + "scenario": { + "const": "bundle.v1" + }, + "given": { + "type": "object", + "additionalProperties": false, + "required": [ + "operation" + ], + "properties": { + "operation": { + "enum": [ + "canonical", + "member-forbidden", + "receipt", + "public", + "private", + "collision" + ] + } + } + }, + "checks": { + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "equals" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "equals": {} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "not_equals" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "not_equals": {} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "contains" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "contains": {} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "absent" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "absent": { + "const": true + } + } + } + ] + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "case", + "scenario", + "given", + "checks" + ], + "properties": { + "case": { + "type": "string", + "minLength": 1 + }, + "scenario": { + "const": "history-store.v1" + }, + "given": { + "type": "object", + "additionalProperties": false, + "required": [ + "operation" + ], + "properties": { + "operation": { + "enum": [ + "idempotent", + "failed-commit", + "missing-predecessor", + "forbidden", + "two-writers" + ] + } + } + }, + "checks": { + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "equals" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "equals": {} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "not_equals" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "not_equals": {} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "contains" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "contains": {} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "absent" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "absent": { + "const": true + } + } + } + ] + } + } + } + } + ] + } + } + } +} diff --git a/scripts/check_conformance_surface.py b/scripts/check_conformance_surface.py new file mode 100644 index 0000000..ddd5ccc --- /dev/null +++ b/scripts/check_conformance_surface.py @@ -0,0 +1,25 @@ +"""Run the exact published operation bridge from a source checkout, offline.""" + +from pathlib import Path +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT / "src"), str(ROOT / "tests")] +from surface_bridge import CONTRACT, execute, load +from devostasis.vectors import load as load_vectors + + +def main(): + cases = load(ROOT / "tests/fixtures/conformance-surface.json") + old = {v.case for v in load_vectors([ROOT / "tests/vectors"])} + if old.intersection(c["case"] for c in cases): raise ValueError("duplicate permanent case across carriers") + for case in cases: + with tempfile.TemporaryDirectory(prefix="devostasis-conformance-") as directory: + execute(case, Path(directory)) + print(case["case"], "PASS") + print(CONTRACT, len(cases), "passed") + + +if __name__ == "__main__": + main() diff --git a/src/devostasis/adapters/gitlab_ci.py b/src/devostasis/adapters/gitlab_ci.py new file mode 100644 index 0000000..9fa1b21 --- /dev/null +++ b/src/devostasis/adapters/gitlab_ci.py @@ -0,0 +1,19 @@ +"""Offline GitLab native outcome boundary under PV-CI-NORM-001. + +This pure normalizer is not a collector and cannot establish history coverage. +Pipeline ids remain distinct; an in-place retry keeps its pipeline identity. +""" + +from .github_ci import (VERIFY_PASS, VERIFY_FAIL, VERIFY_UNRESOLVED, + NON_VERIFY_TERMINAL, NOT_EXECUTED, UNKNOWN) + +OUTCOME_MAP_VERSION = "devostasis.ci-outcomes.gitlab.v1" +OUTCOME_MAP = {"success": VERIFY_PASS, "failed": VERIFY_FAIL, + "canceled": NON_VERIFY_TERMINAL, "skipped": NOT_EXECUTED, + "manual": NOT_EXECUTED, "scheduled": NOT_EXECUTED} +NONTERMINAL = frozenset({"created", "waiting_for_resource", "preparing", "waiting_for_callback", + "pending", "running", "canceling"}) + + +def normalize_outcome(status): + return VERIFY_UNRESOLVED if status in NONTERMINAL else OUTCOME_MAP.get(status, UNKNOWN) diff --git a/src/devostasis/history.py b/src/devostasis/history.py index 5caf7d0..87529ec 100644 --- a/src/devostasis/history.py +++ b/src/devostasis/history.py @@ -75,6 +75,10 @@ class ImmutabilityError(HistoryStoreError): """Raised when an existing immutable bundle would be overwritten with different content.""" +class HistoryConflictError(HistoryStoreError): + """A publisher must rebuild against the latest committed predecessor.""" + + class IdentityConflictError(HistoryStoreError): """Raised when a locator is claimed by a project that is not the one being written.""" @@ -191,10 +195,12 @@ def _identity_of(index: dict[str, Any] | None) -> str | None: def _read_index_at(self, directory: Path) -> dict[str, Any] | None: path = directory / "index.json" - if not path.exists(): - return None try: index = canonical.load_file(path) + except FileNotFoundError: + return None + except PermissionError as exc: + raise HistoryStoreError(f"HISTORY_STORE_FORBIDDEN: index unreadable at {self._rel(directory)}") from exc except Exception as exc: # noqa: BLE001 raise HistoryStoreError(f"index unreadable at {self._rel(directory)}: {type(exc).__name__}") from exc problems = index_problems(index) @@ -568,6 +574,17 @@ def commit( indexed bundle, which the next commit replaces, and never an index that names a bundle the copy contradicts. """ + from .workscope.bundle import locked + from .workscope.model import ScopeError + + self.root.mkdir(parents=True, exist_ok=True) + try: + with locked(self.root / ".history-store-lock"): + return self._commit_locked(bundle, bands, gauges, top_attention) + except ScopeError as exc: + raise HistoryConflictError(f"HISTORY_WRITE_CONFLICT: {exc}") from exc + + def _commit_locked(self, bundle, bands, gauges, top_attention): from .demand import top_attention as _top problems = self._binding_problems(bundle) @@ -580,6 +597,15 @@ def commit( self._relocate(location) directory = location.directory path = self.put_immutable(bundle, directory) + index = self.read_index(bundle.project_key, directory) + entries = index["bundles"] + if entries and not any(e["bundle_id"] == bundle.bundle_id for e in entries): + tail = entries[-1] + if (bundle.observed_at, bundle.bundle_id) > (tail["observed_at"], tail["bundle_id"]): + if bundle.manifest.get("previous_bundle_id") != tail["bundle_id"]: + # Preserve the immutable candidate; never publish a stale writer + # as canonical latest. The caller retries after rebuilding. + raise HistoryConflictError("HISTORY_WRITE_CONFLICT: predecessor changed; rebuild against latest") bands = bands if bands is not None else bundle.bands() gauges = gauges if gauges is not None else bundle.gauges() if top_attention is None: diff --git a/src/devostasis/vitals/integrity.py b/src/devostasis/vitals/integrity.py index 75c8c7b..72ffaaf 100644 --- a/src/devostasis/vitals/integrity.py +++ b/src/devostasis/vitals/integrity.py @@ -214,6 +214,32 @@ def _unknown_current(revisions: list[dict[str, Any]], diagnostics: list[str], hi def evaluate(obs: ObservationSet) -> VitalResult: + """Exact legacy evaluation, or the explicit independence-provenance extension. + + An absent discriminator preserves historical bytes. A present discriminator + is consumed, versioned and fail closed; retries never create revision samples. + """ + result = _evaluate_revisions(obs) + provenance = obs.get("ci.sample_semantics") + if provenance is None: + return result + result.rule_id = RULE_ID + "+independence-001" + result.inputs += input_meta(obs, ["ci.sample_semantics"]) + result.derived["sample_semantics"] = provenance.value if provenance.good else None + if not provenance.good or provenance.value_type != "enum" or provenance.value not in ("RAW_RUNS", "INDEPENDENT_REVISIONS"): + result.band = result.band_semantics = result.possible_bands = None + result.evaluation_status = EVAL_UNKNOWN + result.diagnostics.append("CI_SAMPLE_INDEPENDENCE_UNKNOWN") + elif provenance.value == "RAW_RUNS": + result.derived["sample_strength"] = "PROVISIONAL_NONINDEPENDENT" + result.diagnostics.append("CI_NONINDEPENDENT_SAMPLE") + if result.evaluation_status == EVAL_AVAILABLE: + result.evaluation_status = EVAL_DEGRADED + result.explanation += " Raw execution provenance cannot establish independent revision samples." + return result + + +def _evaluate_revisions(obs: ObservationSet) -> VitalResult: diagnostics: list[str] = [] reconciled = reconcile(obs) history = {"revision_history": reconciled.output()} diff --git a/tests/fixtures/conformance-surface.json b/tests/fixtures/conformance-surface.json new file mode 100644 index 0000000..b2300da --- /dev/null +++ b/tests/fixtures/conformance-surface.json @@ -0,0 +1,1157 @@ +{ + "schema": "devostasis.conformance-bridge.v1", + "source": "PV-CONFORMANCE-SURFACE-001; PV-REV-CONFORMANCE-SURFACE-001; implementation materialization awaiting independent fixture review", + "cases": [ + { + "case": "R5", + "scenario": "ci-native.v1", + "given": { + "variants": [ + { + "name": "skipped", + "provider": "github", + "mapping": "devostasis.ci-outcomes.github.v1", + "status": "completed", + "conclusion": "skipped" + } + ] + }, + "checks": [ + { + "path": [ + "skipped", + "outcome" + ], + "equals": "NOT_EXECUTED" + }, + { + "path": [ + "skipped", + "revision_verdict" + ], + "equals": "NOT_EXECUTED" + }, + { + "path": [ + "skipped", + "decisive" + ], + "equals": 0 + }, + { + "path": [ + "skipped", + "failed" + ], + "equals": 0 + } + ] + }, + { + "case": "R6", + "scenario": "ci-native.v1", + "given": { + "variants": [ + { + "name": "manual", + "provider": "gitlab", + "mapping": "devostasis.ci-outcomes.gitlab.v1", + "status": "manual", + "conclusion": null + } + ] + }, + "checks": [ + { + "path": [ + "manual", + "outcome" + ], + "equals": "NOT_EXECUTED" + }, + { + "path": [ + "manual", + "revision_verdict" + ], + "equals": "NOT_EXECUTED" + }, + { + "path": [ + "manual", + "decisive" + ], + "equals": 0 + }, + { + "path": [ + "manual", + "failed" + ], + "equals": 0 + } + ] + }, + { + "case": "R7", + "scenario": "ci-native.v1", + "given": { + "variants": [ + { + "name": "timeout", + "provider": "github", + "mapping": "devostasis.ci-outcomes.github.v1", + "status": "completed", + "conclusion": "timed_out" + } + ] + }, + "checks": [ + { + "path": [ + "timeout", + "outcome" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "timeout", + "revision_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "timeout", + "decisive" + ], + "equals": 1 + }, + { + "path": [ + "timeout", + "failed" + ], + "equals": 1 + } + ] + }, + { + "case": "R8", + "scenario": "ci-native.v1", + "given": { + "variants": [ + { + "name": "cancelled", + "provider": "github", + "mapping": "devostasis.ci-outcomes.github.v1", + "status": "completed", + "conclusion": "cancelled" + } + ] + }, + "checks": [ + { + "path": [ + "cancelled", + "outcome" + ], + "equals": "NON_VERIFY_TERMINAL" + }, + { + "path": [ + "cancelled", + "revision_verdict" + ], + "equals": "NON_VERIFY_TERMINAL" + }, + { + "path": [ + "cancelled", + "decisive" + ], + "equals": 0 + }, + { + "path": [ + "cancelled", + "failed" + ], + "equals": 0 + } + ] + }, + { + "case": "R9", + "scenario": "ci-native.v1", + "given": { + "variants": [ + { + "name": "github", + "provider": "github", + "mapping": "devostasis.ci-outcomes.github.v1", + "status": "completed", + "conclusion": "failure" + }, + { + "name": "gitlab", + "provider": "gitlab", + "mapping": "devostasis.ci-outcomes.gitlab.v1", + "status": "failed", + "conclusion": null + } + ] + }, + "checks": [ + { + "path": [ + "github", + "outcome" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "github", + "revision_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "github", + "decisive" + ], + "equals": 1 + }, + { + "path": [ + "github", + "failed" + ], + "equals": 1 + }, + { + "path": [ + "gitlab", + "outcome" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "gitlab", + "revision_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "gitlab", + "decisive" + ], + "equals": 1 + }, + { + "path": [ + "gitlab", + "failed" + ], + "equals": 1 + } + ] + }, + { + "case": "R10", + "scenario": "ci-native.v1", + "given": { + "variants": [ + { + "name": "github", + "provider": "github", + "mapping": "devostasis.ci-outcomes.github.v1", + "status": "completed", + "conclusion": "future-result" + }, + { + "name": "gitlab", + "provider": "gitlab", + "mapping": "devostasis.ci-outcomes.gitlab.v1", + "status": "future-result", + "conclusion": null + } + ] + }, + "checks": [ + { + "path": [ + "github", + "outcome" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "github", + "revision_verdict" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "github", + "decisive" + ], + "equals": 0 + }, + { + "path": [ + "github", + "failed" + ], + "equals": 0 + }, + { + "path": [ + "gitlab", + "outcome" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "gitlab", + "revision_verdict" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "gitlab", + "decisive" + ], + "equals": 0 + }, + { + "path": [ + "gitlab", + "failed" + ], + "equals": 0 + } + ] + }, + { + "case": "T3", + "scenario": "independence.v1", + "given": { + "attempts": [ + "failure", + "success", + "success", + "success", + "success", + "success", + "success", + "success", + "success", + "success", + "success" + ], + "sample_semantics": "RAW_RUNS" + }, + "checks": [ + { + "path": [ + "canonical_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "result", + "evaluation_status" + ], + "equals": "DEGRADED" + }, + { + "path": [ + "result", + "derived", + "sample_strength" + ], + "equals": "PROVISIONAL_NONINDEPENDENT" + }, + { + "path": [ + "result", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "result", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "result", + "derived", + "current_revision", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "result", + "diagnostics" + ], + "contains": "CI_NONINDEPENDENT_SAMPLE" + }, + { + "path": [ + "result", + "rule_id" + ], + "equals": "integrity.bands.v1+ci-unit-004+hist-002+independence-001" + } + ] + }, + { + "case": "T6", + "scenario": "multi-vital.v1", + "given": { + "inventories": { + "git.default_branch.commits_28d": [ + { + "sha": "r", + "committed_at": "2026-09-04T00:00:00Z" + } + ], + "forge.change_requests.inventory": [ + { + "number": 1, + "state": "OPEN", + "created_at": "2026-09-01T00:00:00Z", + "updated_at": "2026-09-04T00:00:00Z", + "merged_at": null + }, + { + "number": 2, + "state": "CLOSED", + "created_at": "2026-09-02T00:00:00Z", + "updated_at": "2026-09-04T00:00:00Z", + "merged_at": "2026-09-04T00:00:00Z" + } + ], + "forge.issues.inventory": [ + { + "number": 1, + "state": "OPEN", + "created_at": "2026-07-01T00:00:00Z", + "updated_at": "2026-07-01T00:00:00Z" + } + ], + "git.nondefault_branches.inventory": [] + }, + "observation_id": null, + "variants": [ + { + "name": "shared", + "status": "AVAILABLE", + "freshness": "FRESH" + } + ] + }, + "checks": [ + { + "path": [ + "shared", + "vitals", + "pulse", + "dependency_group_ids" + ], + "contains": "FLOW_PULSE_ACTIVITY" + }, + { + "path": [ + "shared", + "vitals", + "flow", + "dependency_group_ids" + ], + "contains": "FLOW_PULSE_ACTIVITY" + }, + { + "path": [ + "shared", + "vitals", + "flow", + "dependency_group_ids" + ], + "contains": "CLUTTER_FLOW_FORGE" + }, + { + "path": [ + "shared", + "vitals", + "clutter", + "dependency_group_ids" + ], + "contains": "CLUTTER_FLOW_FORGE" + }, + { + "path": [ + "shared", + "sources", + "forge.change_requests.open_count", + "derived_from" + ], + "equals": [ + "forge.change_requests.inventory" + ] + }, + { + "path": [ + "shared", + "sources", + "forge.change_requests.updated_count_28d", + "derived_from" + ], + "equals": [ + "forge.change_requests.inventory" + ] + }, + { + "path": [ + "shared", + "sources", + "forge.change_requests.stale_open_count_14d", + "derived_from" + ], + "equals": [ + "forge.change_requests.inventory" + ] + }, + { + "path": [ + "shared", + "snapshot", + "aggregate_health" + ], + "absent": true + }, + { + "path": [ + "shared", + "snapshot", + "score" + ], + "absent": true + }, + { + "path": [ + "shared", + "snapshot", + "aggregate" + ], + "absent": true + }, + { + "path": [ + "shared", + "vitals", + "flow", + "band" + ], + "equals": "MOVING" + }, + { + "path": [ + "shared", + "vitals", + "pulse", + "band" + ], + "equals": "QUIET" + }, + { + "path": [ + "shared", + "vitals", + "clutter", + "band" + ], + "equals": "LIGHT" + } + ] + }, + { + "case": "T8", + "scenario": "multi-vital.v1", + "given": { + "inventories": { + "git.default_branch.commits_28d": [ + { + "sha": "r", + "committed_at": "2026-09-04T00:00:00Z" + } + ], + "forge.change_requests.inventory": [ + { + "number": 1, + "state": "OPEN", + "created_at": "2026-09-01T00:00:00Z", + "updated_at": "2026-09-04T00:00:00Z", + "merged_at": null + }, + { + "number": 2, + "state": "CLOSED", + "created_at": "2026-09-02T00:00:00Z", + "updated_at": "2026-09-04T00:00:00Z", + "merged_at": "2026-09-04T00:00:00Z" + } + ], + "forge.issues.inventory": [ + { + "number": 1, + "state": "OPEN", + "created_at": "2026-07-01T00:00:00Z", + "updated_at": "2026-07-01T00:00:00Z" + } + ], + "git.nondefault_branches.inventory": [] + }, + "observation_id": "forge.change_requests.open_count", + "variants": [ + { + "name": "FORBIDDEN", + "status": "FORBIDDEN", + "freshness": "FRESH" + }, + { + "name": "UNKNOWN", + "status": "UNKNOWN", + "freshness": "FRESH" + }, + { + "name": "ERROR", + "status": "ERROR", + "freshness": "FRESH" + }, + { + "name": "PARTIAL", + "status": "PARTIAL", + "freshness": "FRESH" + }, + { + "name": "STALE", + "status": "AVAILABLE", + "freshness": "STALE" + } + ] + }, + "checks": [ + { + "path": [ + "FORBIDDEN", + "vitals", + "flow", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "FORBIDDEN", + "vitals", + "flow", + "band" + ], + "equals": null + }, + { + "path": [ + "FORBIDDEN", + "vitals", + "clutter", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "FORBIDDEN", + "vitals", + "clutter", + "band" + ], + "equals": null + }, + { + "path": [ + "UNKNOWN", + "vitals", + "flow", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "UNKNOWN", + "vitals", + "flow", + "band" + ], + "equals": null + }, + { + "path": [ + "UNKNOWN", + "vitals", + "clutter", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "UNKNOWN", + "vitals", + "clutter", + "band" + ], + "equals": null + }, + { + "path": [ + "ERROR", + "vitals", + "flow", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "ERROR", + "vitals", + "flow", + "band" + ], + "equals": null + }, + { + "path": [ + "ERROR", + "vitals", + "clutter", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "ERROR", + "vitals", + "clutter", + "band" + ], + "equals": null + }, + { + "path": [ + "PARTIAL", + "vitals", + "flow", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "PARTIAL", + "vitals", + "flow", + "band" + ], + "equals": null + }, + { + "path": [ + "PARTIAL", + "vitals", + "clutter", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "PARTIAL", + "vitals", + "clutter", + "band" + ], + "equals": null + }, + { + "path": [ + "STALE", + "vitals", + "flow", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "STALE", + "vitals", + "flow", + "band" + ], + "equals": null + }, + { + "path": [ + "STALE", + "vitals", + "clutter", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "STALE", + "vitals", + "clutter", + "band" + ], + "equals": null + } + ] + }, + { + "case": "ART-05", + "scenario": "bundle.v1", + "given": { + "operation": "member-forbidden" + }, + "checks": [ + { + "path": [ + "access" + ], + "equals": "FORBIDDEN" + }, + { + "path": [ + "verified" + ], + "equals": false + } + ] + }, + { + "case": "ART-08", + "scenario": "bundle.v1", + "given": { + "operation": "canonical" + }, + "checks": [ + { + "path": [ + "same_bytes" + ], + "equals": true + }, + { + "path": [ + "same_digest" + ], + "equals": true + }, + { + "path": [ + "scalar_differs" + ], + "equals": true + }, + { + "path": [ + "type_differs" + ], + "equals": true + } + ] + }, + { + "case": "ART-09", + "scenario": "bundle.v1", + "given": { + "operation": "receipt" + }, + "checks": [ + { + "path": [ + "original_verified" + ], + "equals": true + }, + { + "path": [ + "missing_receipt_verified" + ], + "equals": false + }, + { + "path": [ + "references_resolve" + ], + "equals": true + }, + { + "path": [ + "receipt_binding_rejected" + ], + "equals": true + } + ] + }, + { + "case": "ART-10", + "scenario": "bundle.v1", + "given": { + "operation": "public" + }, + "checks": [ + { + "path": [ + "verified" + ], + "equals": true + }, + { + "path": [ + "report_present" + ], + "equals": true + } + ] + }, + { + "case": "ART-11", + "scenario": "bundle.v1", + "given": { + "operation": "private" + }, + "checks": [ + { + "path": [ + "same_id" + ], + "equals": true + }, + { + "path": [ + "same_members" + ], + "equals": true + }, + { + "path": [ + "verified" + ], + "equals": true + } + ] + }, + { + "case": "ART-15", + "scenario": "bundle.v1", + "given": { + "operation": "collision" + }, + "checks": [ + { + "path": [ + "collision" + ], + "equals": true + }, + { + "path": [ + "unchanged" + ], + "equals": true + } + ] + }, + { + "case": "RPT-4", + "scenario": "history-store.v1", + "given": { + "operation": "idempotent" + }, + "checks": [ + { + "path": [ + "same_path" + ], + "equals": true + }, + { + "path": [ + "same_index" + ], + "equals": true + }, + { + "path": [ + "collision" + ], + "equals": true + } + ] + }, + { + "case": "RPT-5", + "scenario": "history-store.v1", + "given": { + "operation": "failed-commit" + }, + "checks": [ + { + "path": [ + "failed" + ], + "equals": true + }, + { + "path": [ + "latest_preserved" + ], + "equals": true + }, + { + "path": [ + "first_verified" + ], + "equals": true + } + ] + }, + { + "case": "RPT-6", + "scenario": "history-store.v1", + "given": { + "operation": "missing-predecessor" + }, + "checks": [ + { + "path": [ + "comparison" + ], + "equals": "HISTORY_GAP" + }, + { + "path": [ + "snapshot_present" + ], + "equals": true + }, + { + "path": [ + "transitions" + ], + "equals": [ + "INCOMPARABLE" + ] + } + ] + }, + { + "case": "RPT-8", + "scenario": "history-store.v1", + "given": { + "operation": "forbidden" + }, + "checks": [ + { + "path": [ + "comparison" + ], + "equals": "HISTORY_GAP" + }, + { + "path": [ + "previous_preserved" + ], + "equals": true + }, + { + "path": [ + "forbidden_explicit" + ], + "equals": true + } + ] + }, + { + "case": "RPT-9", + "scenario": "history-store.v1", + "given": { + "operation": "two-writers" + }, + "checks": [ + { + "path": [ + "conflict" + ], + "equals": true + }, + { + "path": [ + "checkpoint_latest_a" + ], + "equals": true + }, + { + "path": [ + "immutable_candidates_preserved" + ], + "equals": true + }, + { + "path": [ + "retry_uses_a" + ], + "equals": true + }, + { + "path": [ + "latest_retry" + ], + "equals": true + }, + { + "path": [ + "locked_writer_refused" + ], + "equals": true + } + ] + } + ] +} diff --git a/tests/surface_bridge.py b/tests/surface_bridge.py new file mode 100644 index 0000000..2b5a09b --- /dev/null +++ b/tests/surface_bridge.py @@ -0,0 +1,287 @@ +"""Version-pinned bridge to isolated, self-contained conformance scenarios. + +No case is a pointer to a test name: declared inputs execute the operation and +every literal/negative assertion is checked against its returned checkpoints. +""" + +from copy import deepcopy +import json +from pathlib import Path +from unittest.mock import patch + +from devostasis import canonical +from devostasis.adapters import github_ci, gitlab_ci +from devostasis.bundle import verify_dir, verify_members +from devostasis.config import single_project +from devostasis.history import FilesystemHistoryStore, HistoryConflictError, ImmutabilityError +from devostasis.observations import Observation +from devostasis.runner import build_from_observations +from devostasis.vitals import build_snapshot, integrity +from helpers import add, finalize, full_inputs, integrity_inputs, obs_set + +CONTRACT = "devostasis.conformance-bridge.v1" +BUNDLE_LINEAGE = {"schema": "devostasis.manifest.v2", "artifact_contract_version": "devostasis.bundle.v3", + "bundle_identity_contract": "PV-BUNDLE-ID-003", "receipt_identity_contract": "PV-RECEIPT-IDENTITY-003"} + + +def shape(value, keys): + if not isinstance(value, dict) or set(value) != set(keys): + raise ValueError(f"expected fields {sorted(keys)}") + + +def exact_bundle_lineage(value): + if any(value.manifest.get(k) != v for k, v in BUNDLE_LINEAGE.items()): + raise ValueError("bridge does not support this exact canonical bundle lineage") + + +def native(given, root): + shape(given, ("variants",)) + result = {} + for row in given["variants"]: + shape(row, ("name", "provider", "mapping", "status", "conclusion")) + if row["name"] in result: raise ValueError("duplicate variant") + if row["provider"] == "github" and row["mapping"] == github_ci.OUTCOME_MAP_VERSION: + outcome = github_ci.normalize_outcome(row["status"], row["conclusion"]) + elif row["provider"] == "gitlab" and row["mapping"] == gitlab_ci.OUTCOME_MAP_VERSION: + if row["conclusion"] is not None: raise ValueError("GitLab has no separate conclusion") + outcome = gitlab_ci.normalize_outcome(row["status"]) + else: + raise ValueError("unsupported exact provider normalization lineage") + # Feed normalized outcomes into the real revision composition too. + result[row["name"]] = {"outcome": outcome, + "revision_verdict": github_ci.compose_current([outcome]), + "decisive": int(outcome in ("VERIFY_PASS", "VERIFY_FAIL")), + "failed": int(outcome == "VERIFY_FAIL")} + return result + + +def independence(given, root): + shape(given, ("attempts", "sample_semantics")) + states = given["attempts"] + if not isinstance(states, list) or not states: raise ValueError("attempts required") + attempts = [{"run_attempt": n + 1, "status": "completed", "conclusion": value} for n, value in enumerate(states)] + run = {"id": 700, **attempts[-1]} + records = github_ci.build_revision_records([{"sha": "one-immutable-revision", "committed_at": "2026-09-04T00:00:00Z"}], + {"one-immutable-revision": [github_ci.actions_parent(run, attempts[:-1])]}) + obs = obs_set(); integrity_inputs(obs, True, records) + add(obs, "ci.sample_semantics", given["sample_semantics"], "enum") + result = integrity.evaluate(obs).to_dict() + return {"canonical_revision_count": len(records), "result": result} + + +def multivital(given, root): + shape(given, ("observation_id", "variants", "inventories")) + results = {} + for variant in given["variants"]: + shape(variant, ("name", "status", "freshness")) + if variant["name"] in results: raise ValueError("duplicate variant") + from devostasis.normalize import derive + obs = obs_set() + allowed = {"git.default_branch.commits_28d", "forge.change_requests.inventory", + "forge.issues.inventory", "git.nondefault_branches.inventory"} + if set(given["inventories"]) != allowed: raise ValueError("shared inventories must be explicit") + record_keys = {"git.default_branch.commits_28d": {"sha", "committed_at"}, + "forge.change_requests.inventory": {"number", "state", "created_at", "updated_at", "merged_at"}, + "forge.issues.inventory": {"number", "state", "created_at", "updated_at"}, + "git.nondefault_branches.inventory": {"name", "committed_at"}} + for oid, rows in given["inventories"].items(): + if not isinstance(rows, list): raise ValueError("inventory must be a list") + for row in rows: shape(row, record_keys[oid]) + add(obs, oid, deepcopy(rows), "series", coverage={"complete": True, "open_complete": True, "window_complete": True}) + derive(obs, single_project("acme/widget", config_version="1")) + finalize(obs) + if given["observation_id"] is not None: + old = obs.get(given["observation_id"]) + if old is None: raise ValueError("unknown substituted observation") + value = old.value if variant["status"] in ("AVAILABLE", "PARTIAL") else None + obs.replace(Observation(**{**old.to_dict(), "value": value, "status": variant["status"], "freshness": variant["freshness"]})) + finalize(obs) + elif variant != {"name": "shared", "status": "AVAILABLE", "freshness": "FRESH"}: + raise ValueError("shared graph does not substitute evidence") + snapshot = build_snapshot(obs) + results[variant["name"]] = {"snapshot": snapshot, + "vitals": {r["vital_id"]: r for r in snapshot["vitals"]}, + "sources": {item.observation_id: item.evidence_ref for item in obs if item.evidence_ref}} + return results + + +def bundle(given, root): + shape(given, ("operation",)) + store = FilesystemHistoryStore(root) + project = single_project("acme/widget", config_version="1") + value = build_from_observations(project, full_inputs(obs_set()), store) + exact_bundle_lineage(value) + path = store.commit(value) + before = dict(value.members) + op = given["operation"] + if op == "canonical": + a = canonical.loads('{"a":1,"b":[true,null]}') + b = canonical.loads(' { "b" : [true, null], "a" : 1 } ') + return {"same_bytes": canonical.canonical_bytes(a) == canonical.canonical_bytes(b), + "same_digest": canonical.digest(a) == canonical.digest(b), + "scalar_differs": canonical.digest(a) != canonical.digest({"a": 2, "b": [True, None]}), + "type_differs": canonical.digest(a) != canonical.digest({"a": True, "b": [True, None]})} + if op == "member-forbidden": + original = Path.read_bytes + def read(file): + if file == path / "snapshot.json": raise PermissionError("FORBIDDEN") + return original(file) + with patch.object(Path, "read_bytes", read): + try: problems = verify_dir(path) + except PermissionError: return {"access": "FORBIDDEN", "verified": False} + return {"access": "FORBIDDEN", "verified": not problems} + if op == "receipt": + content = dict(value.members); content.pop("source-receipts.json", None) + # Current lineage persists receipt identity in the observation member. + observations = canonical.loads(content["observations.json"].decode()) + receipt = observations["receipt_identity"] + resolved = all(item["observation_id"] in receipt["requested_keys"] + and item["observation_id"] in receipt["per_key"] for item in observations["observations"] + if item["provider"] != "history") + carrier = next(i for i in observations["observations"] if i["observation_id"] == "ci.revision_history_carried") + # The internal carrier resolves through its own identity-bearing lineage, + # not through a provider collection receipt that predates attachment. + resolved = resolved and carrier["status"] == "UNAVAILABLE" and carrier["reason_code"] == "NO_PREVIOUS_BUNDLE" + observations["receipt_identity"] = None + content["observations.json"] = canonical.canonical_bytes(observations) + problems = verify_members(content) + return {"original_verified": verify_members(before) == [], "references_resolve": resolved, + "missing_receipt_verified": problems == [], + "receipt_binding_rejected": any("RECEIPT_COPY_MISMATCH" in p for p in problems)} + if op == "public": + # Verification consumes the persisted members, with all network disabled. + with patch("urllib.request.urlopen", side_effect=AssertionError("network dependency")): + return {"verified": verify_dir(path) == [], "report_present": bool((path / "report.md").read_bytes())} + if op == "private": + (root / "private-calibration.json").write_text('{"untrusted":"private"}', encoding="utf-8") + (root / "SNAP.txt").write_text("convenience", encoding="utf-8") + rebuilt = build_from_observations(project, full_inputs(obs_set()), FilesystemHistoryStore(root / "isolated")) + return {"same_id": rebuilt.bundle_id == value.bundle_id, "same_members": rebuilt.members == before, + "verified": verify_dir(path) == []} + if op == "collision": + value.members["snapshot.json"] += b" " + try: store.put_immutable(value) + except ImmutabilityError: + return {"collision": True, "unchanged": all((path / name).read_bytes() == data for name, data in before.items())} + return {"collision": False, "unchanged": False} + raise ValueError("unsupported bundle operation") + + +def history(given, root): + shape(given, ("operation",)) + store = FilesystemHistoryStore(root) + project = single_project("acme/widget", config_version="1") + first = build_from_observations(project, full_inputs(obs_set()), store) + exact_bundle_lineage(first) + path = store.commit(first) + key = first.project_key + second = build_from_observations(project, full_inputs(obs_set("2026-09-06T12:00:00Z")), store) + op = given["operation"] + if op == "idempotent": + original = store.index_path(key).read_bytes() + store.commit(first) + second_path = store.put_immutable(first) + first.members["snapshot.json"] += b" " + try: store.put_immutable(first) + except ImmutabilityError: collision = True + else: collision = False + return {"same_path": path == second_path, "same_index": original == store.index_path(key).read_bytes(), "collision": collision} + if op == "failed-commit": + with patch.object(store, "put_immutable", side_effect=OSError("incomplete immutable commit")): + try: store.commit(second) + except OSError: failed = True + else: failed = False + return {"failed": failed, "latest_preserved": store.latest(key, "123456").bundle_id == first.bundle_id, + "first_verified": verify_dir(path) == []} + if op == "missing-predecessor": + (path / "snapshot.json").unlink() + current = build_from_observations(project, full_inputs(obs_set("2026-09-06T12:00:00Z")), store) + snapshot = canonical.loads(current.members["snapshot.json"].decode()) + delta = canonical.loads(current.members["delta.json"].decode()) + return {"comparison": current.manifest["comparison_status"], "snapshot_present": len(snapshot["vitals"]) == 7, + "transitions": sorted(set(r["transition_class"] for r in delta["vitals"]))} + if op == "forbidden": + from devostasis import history as history_module + original = canonical.load_file + def read(file, *args, **kwargs): + if file == store.index_path(key): raise PermissionError("FORBIDDEN") + return original(file, *args, **kwargs) + with patch.object(history_module.canonical, "load_file", read): + current = build_from_observations(project, full_inputs(obs_set("2026-09-06T12:00:00Z")), store) + state = store.latest(key, "123456") + return {"comparison": current.manifest["comparison_status"], "forbidden_explicit": any("HISTORY_STORE_FORBIDDEN" in p for p in state.problems), + "previous_preserved": verify_dir(path) == []} + if op == "two-writers": + # Both writers have already read first. Force writer A to commit before + # writer B, then inspect the stale writer and its immutable candidate. + third = build_from_observations(project, full_inputs(obs_set("2026-09-07T12:00:00Z")), store) + from devostasis.workscope.bundle import locked + with locked(root / ".history-store-lock"): + try: store.commit(second) + except HistoryConflictError: locked_writer_refused = True + else: locked_writer_refused = False + a_path = store.commit(second) + try: store.commit(third) + except HistoryConflictError: conflict = True + else: conflict = False + checkpoint = store.latest(key, "123456").bundle_id == second.bundle_id + candidate = store.history_dir(third) + rebuilt = build_from_observations(project, full_inputs(obs_set("2026-09-07T12:00:00Z")), store) + b_path = store.commit(rebuilt) + return {"conflict": conflict, "locked_writer_refused": locked_writer_refused, "checkpoint_latest_a": checkpoint, + "immutable_candidates_preserved": all(verify_dir(p) == [] for p in (path, a_path, candidate, b_path)), + "retry_uses_a": rebuilt.manifest["previous_bundle_id"] == second.bundle_id, + "latest_retry": store.latest(key, "123456").bundle_id == rebuilt.bundle_id} + raise ValueError("unsupported history operation") + + +SCENARIOS = {"ci-native.v1": native, "independence.v1": independence, + "multi-vital.v1": multivital, "bundle.v1": bundle, "history-store.v1": history} + + +def load(path): + document = canonical.loads(Path(path).read_text(encoding="utf-8")) + shape(document, ("schema", "source", "cases")) + if document["schema"] != CONTRACT: raise ValueError("unknown bridge lineage") + if not isinstance(document["source"], str) or not document["source"]: raise ValueError("source required") + seen = set() + for case in document["cases"]: + shape(case, ("case", "scenario", "given", "checks")) + if not isinstance(case["case"], str) or case["case"] in seen: raise ValueError("duplicate or invalid case") + seen.add(case["case"]) + if case["scenario"] not in SCENARIOS: raise ValueError("unknown scenario lineage") + if not isinstance(case["given"], dict) or not isinstance(case["checks"], list) or not case["checks"]: + raise ValueError("scenario input and checks required") + for check in case["checks"]: + if not isinstance(check, dict) or set(check) not in ({"path", "equals"}, {"path", "not_equals"}, {"path", "contains"}, {"path", "absent"}): + raise ValueError("unknown assertion") + if not isinstance(check["path"], list) or not all(type(p) in (str, int) for p in check["path"]): + raise ValueError("invalid assertion path") + if "absent" in check and check["absent"] is not True: raise ValueError("absent must be true") + if case["case"] == "T8": + required = {(s, "FRESH") for s in ("FORBIDDEN", "UNKNOWN", "ERROR", "PARTIAL")} | {("AVAILABLE", "STALE")} + variants = case["given"].get("variants", []) + if len(variants) != 5 or {(v.get("status"), v.get("freshness")) for v in variants} != required: + raise ValueError("T8 requires every independent evidence-state substitution") + return document["cases"] + + +def execute(case, root): + actual = SCENARIOS[case["scenario"]](deepcopy(case["given"]), root) + for check in case["checks"]: + node, exists = actual, True + for part in check["path"]: + try: node = node[part] + except (KeyError, IndexError, TypeError): exists = False; break + where = f"{case['case']}:{check['path']}" + if "absent" in check: + assert not exists, f"{where}: forbidden field exists" + continue + assert exists, f"{where}: unknown checkpoint/assertion path" + if "equals" in check: + assert canonical.canonical_bytes(node) == canonical.canonical_bytes(check["equals"]), f"{where}: expected {check['equals']!r}, got {node!r}" + if "not_equals" in check: + assert canonical.canonical_bytes(node) != canonical.canonical_bytes(check["not_equals"]), f"{where}: forbidden value {node!r}" + if "contains" in check: + assert check["contains"] in node, f"{where}: missing {check['contains']!r}" + return actual diff --git a/tests/test_conformance_surface.py b/tests/test_conformance_surface.py new file mode 100644 index 0000000..b56588f --- /dev/null +++ b/tests/test_conformance_surface.py @@ -0,0 +1,67 @@ +"""Executable capability acceptance and fail-closed bridge regression checks.""" + +from copy import deepcopy +from pathlib import Path + +import pytest + +from devostasis import canonical, vectors +from surface_bridge import execute, load + +CASES = Path(__file__).parent / "fixtures" / "conformance-surface.json" + + +@pytest.mark.parametrize("case", load(CASES), ids=lambda c: c["case"]) +def test_conformance_surface(case, tmp_path): + execute(case, tmp_path) + + +def test_bridge_and_v1_do_not_duplicate_permanent_ids(): + old = {v.case for v in vectors.load([Path(__file__).parent / "vectors"])} + assert not old.intersection(c["case"] for c in load(CASES)) + + +@pytest.mark.parametrize("mutation", ["schema", "scenario", "check", "case", "extra"]) +def test_bridge_unknown_and_duplicate_lineage_never_skips(tmp_path, mutation): + document = canonical.loads(CASES.read_text(encoding="utf-8")) + if mutation == "schema": document["schema"] += "+future" + if mutation == "scenario": document["cases"][0]["scenario"] += "+future" + if mutation == "check": document["cases"][0]["checks"][0]["ignored"] = True + if mutation == "case": document["cases"].append(document["cases"][0]) + if mutation == "extra": document["ignored"] = True + file = tmp_path / "bad.json"; file.write_bytes(canonical.canonical_bytes(document)) + with pytest.raises(ValueError): load(file) + + +def test_bridge_consumes_negative_assertions_and_wrong_expectations(tmp_path): + case = deepcopy(next(c for c in load(CASES) if c["case"] == "ART-08")) + case["checks"][0]["equals"] = False + with pytest.raises(AssertionError, match="expected"): execute(case, tmp_path) + + +def test_unknown_input_and_assertion_paths_cannot_pass(tmp_path): + case = deepcopy(next(c for c in load(CASES) if c["case"] == "R5")) + case["given"]["ignored"] = True + with pytest.raises(ValueError): execute(case, tmp_path) + case["given"].pop("ignored") + case["checks"] = [{"path": ["not-a-checkpoint"], "equals": None}] + with pytest.raises(AssertionError, match="unknown checkpoint"): execute(case, tmp_path) + + +def test_t8_cannot_drop_a_required_substitution(tmp_path): + document = canonical.loads(CASES.read_text(encoding="utf-8")) + case = next(c for c in document["cases"] if c["case"] == "T8") + case["given"]["variants"].pop() + file = tmp_path / "incomplete.json"; file.write_bytes(canonical.canonical_bytes(document)) + with pytest.raises(ValueError, match="every independent"): load(file) + + +@pytest.mark.parametrize("status,freshness,value", [("UNKNOWN", "FRESH", None), ("AVAILABLE", "STALE", "RAW_RUNS"), ("AVAILABLE", "FRESH", "FUTURE")]) +def test_independence_provenance_is_consumed_fail_closed(status, freshness, value): + from helpers import add, obs_set, integrity_inputs, passing_revisions + from devostasis.vitals.integrity import evaluate + obs = obs_set(); integrity_inputs(obs, True, passing_revisions(4)) + add(obs, "ci.sample_semantics", value, "enum", status=status, freshness=freshness) + result = evaluate(obs) + assert result.evaluation_status == "UNKNOWN" and result.band is None + assert "CI_SAMPLE_INDEPENDENCE_UNKNOWN" in result.diagnostics From 364c44fd664307520e3154665f233f2deb858174 Mon Sep 17 00:00:00 2001 From: Unisphere Dev Date: Wed, 21 Oct 2026 16:16:02 +0300 Subject: [PATCH 31/33] docs(release): publish acceptance and review gates through 0.6 --- .devostasis/debt.json | 11 +-- CHANGELOG.md | 21 ++++- README.md | 5 ++ ROADMAP.md | 17 ++-- docs/release-acceptance.md | 133 +++++++++++++++++++++++++++++++ docs/spec/PROVENANCE.md | 9 ++- docs/spec/README.md | 1 + docs/spec/conformance-surface.md | 71 +++++++++++++++++ docs/spec/conformance.md | 65 ++++++++++++--- docs/spec/history-and-reports.md | 12 +++ docs/spec/integrity-ci.md | 11 +++ docs/spec/observations.md | 8 ++ docs/spec/vitals.md | 11 +++ tests/test_spec_drift.py | 8 ++ 14 files changed, 351 insertions(+), 32 deletions(-) create mode 100644 docs/release-acceptance.md create mode 100644 docs/spec/conformance-surface.md diff --git a/.devostasis/debt.json b/.devostasis/debt.json index 78883ee..7c5587e 100644 --- a/.devostasis/debt.json +++ b/.devostasis/debt.json @@ -5,10 +5,11 @@ { "id": "D-10", "title": "Native change hotspots and local roots rely on recorded acquisition rather than complete ancestry proofs", - "state": "open", + "state": "closed", "opened": "2026-10-08", - "updated": "2026-10-08", - "cost": "0.6 source proofs bind pinned commit/tree and admitted blob bytes, but replay of recorded history counts/root traversal is not independent admission of their complete ancestry. A rehashed recorded history can alter hotspots. Counts remain descriptive and separately covered, with no health or acceptance authority. Bounded historic object/parent proof and preserved v1 replay are issue #73." + "updated": "2026-10-21", + "cost": "Implemented in the pending 0.6 assurance increment: code-analysis v2 authenticates bounded commit/parent/tree/blob proofs and independently derives roots, selection, completeness and edit counts offline. Rehashed claims are rejected; caps/shallow/missing objects remain explicit PARTIAL. V1 exact bytes/replay retain their documented acquisition-only limit. Independent PR review and maintainer release remain pending; issue #73.", + "closed": "2026-10-21" }, { "id": "D-7", @@ -48,8 +49,8 @@ "title": "Conformance cases named in the specification have no executable fixture", "state": "open", "opened": "2026-09-05", - "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)." + "updated": "2026-10-21", + "cost": "0.6 publishes a versioned operation bridge with 20 permanent implementation fixtures: R5-R10 native normalization, T3/T6/T8, ART-05/08/09/10/11/15 and RPT-4/5/6/8/9. Nineteen address the former list of 62; R11-R53 (43 cases) remain without exact materialization. Concrete surface and whole-case exact-fixture independent review remain pending, including downstream R5-R10 fidelity. B1 is open; capability acceptance is not fixture acceptance. T9 cell-key delivery remains separate." }, { "id": "D-2", diff --git a/CHANGELOG.md b/CHANGELOG.md index bd79b63..36fc0a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,20 @@ contract or a policy always come with a version bump of that contract. ## 0.6.0 (unreleased) -Offline Repository Analysis, target E4, issue #72. +Offline Repository Analysis and executable assurance, targets E4/B1, issues #72/#73. + +- Default native analysis v2 carries bounded authenticated commit/parent/tree/blob + history proof; offline replay derives roots, window membership, edit counts and + completeness. Merge ancestry, clock skew, shallow/missing objects and resource + caps remain explicit. V1 bundle/packet/example bytes retain exact replay. +- Publish a versioned source-checkout conformance operation bridge with 20 permanent + implementation fixtures, consumed RAW_RUNS provenance, shared multi-Vital evidence, + all T8 substitutions, canonical bundle and actual HistoryStore checkpoints. +- Serialize canonical store publication and reject stale advancing predecessors + with explicit retry conflict while preserving immutable candidates. Store index + permission denial remains FORBIDDEN/HISTORY_GAP. +- Add per-version 0.2–0.6 acceptance ledger and independent review/release gates; + B1 exact research acceptance and sustained external B7 evidence remain open. - Release-readiness repairs: reject ambiguous/malformed current CI inventory before it can remove carried failures; preserve immutable revision time; @@ -35,9 +48,9 @@ Offline Repository Analysis, target E4, issue #72. GitHub totals while preserving observed partial rows (#70). - Isolate inherited Git context and unrelated credential environment variables from local analysis subprocesses and synthetic diagnostics (#74). -- Independent ancestry/count proof remains documented debt (#73); external - scheduling/adoption is deferred (#71). Existing Vitals, core/work replay, - B1/B7 and upstream release/review gates retain their contracts. +- Independent ancestry/count proof is implemented under the separate v2 lineage + (#73); v1 retains its historical limits. External scheduling/adoption remains + deferred (#71), and B1/B7 and upstream release/review gates remain explicit. ## 0.5.0 (unreleased) diff --git a/README.md b/README.md index 980dc80..6c85ae2 100644 --- a/README.md +++ b/README.md @@ -369,3 +369,8 @@ than patched ad hoc. ## License [MIT](LICENSE). + +The 0.6 release candidate adds independently replayable Git history proof and a +versioned conformance operation bridge. See the [0.2–0.6 acceptance ledger](docs/release-acceptance.md) +for implementation checks, historical compatibility and pending independent +review/release gates. Existing published pins remain unchanged until release. diff --git a/ROADMAP.md b/ROADMAP.md index dba863e..7e8bfab 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,8 +7,8 @@ started and work that cannot start. ## Release state and next steps — 2026-10-21 The project is at **release stabilization**, with Phase A closed and Phase B -five of seven items implemented. B1 still has 62 named cases without executable -proof, and B7 still needs sustained outside-consumer evidence. The latest +five of seven items implemented. B1 now has 20 operation-bridge implementation fixtures (19 from the former +62-case gap); R11–R53 and independent whole-case fixture acceptance remain open, and B7 still needs sustained outside-consumer evidence. The latest published version is **v0.1.9**; 0.2.0 through 0.6.0 remain unreleased. The audit began with a clean working tree. All local and fetched remote heads @@ -52,14 +52,15 @@ on #57/#75 or approve a later repaired head. (#20/#23) and independently proved source-history ancestry/counts (#73). Keep partial coverage, historical compatibility and evidence limits visible. -### Proposed next major code increment +### Assurance delivered within the 0.6 release candidate -After releasing 0.6, the highest-impact code-only increment is **independently -verifiable history and executable assurance**: bounded commit/parent/tree +The owner brought **independently verifiable history and executable assurance** +into 0.6 rather than a later release: bounded commit/parent/tree proofs for #73, hostile rehashed-history controls, and the missing executable conformance boundaries for B1. This strengthens the proof objects on which -both work routing and native hotspots depend. It is a proposal, not acceptance -of a new research contract, a new release number, or completion of B1. +both work routing and native hotspots depend. The v2 proof and versioned operation bridge are implemented; concrete-surface, +exact-fixture and release review remain pending. The [acceptance ledger](docs/release-acceptance.md) +tracks every prepared version. This does not complete B1 or publish a release. The larger product milestone remains a real outside consumer completing work from verified scopes with retained results and calendar history. Deployment @@ -88,7 +89,7 @@ completion, without external deployment or independent acceptance claims. Debt found during this increment: contradictory pagination completeness (#70, fixed with regressions), and independently verifiable history/root/count proof -(#73, currently recorded acquisition observations). Protected scheduled consumer +(#73, implemented as v2 in the assurance increment; v1 remains recorded observations). Protected scheduled consumer adoption (#71) is deferred by the owner. Inherited local Git context and excess credential environment forwarding were isolated and regression-tested (#74). Native language expansion and calibrated diff --git a/docs/release-acceptance.md b/docs/release-acceptance.md new file mode 100644 index 0000000..c27ab6d --- /dev/null +++ b/docs/release-acceptance.md @@ -0,0 +1,133 @@ +# Release acceptance through 0.6 + +This is the cumulative acceptance ledger for the prepared 0.2–0.6 increments. +They remain unreleased until maintainer merges/tags under CONTRIBUTING.md. +The latest published tag observed in the October 21 audit was v0.1.9. +Implementation evidence and independent acceptance are separate gates. + +The assurance increment is part of **0.6.0**, not a new release number. It +includes the October 21 readiness repairs and preserves older canonical bundles. +The pull request records final commit/run links; the commands below reproduce +the acceptance from a source checkout without network or private calibration. + +## 0.2.0 — durable core history and incomplete evidence + +- [x] Current malformed/duplicate revision records cannot erase carried failure + or multiply immutable revision samples; timestamps and attempt identities are + admitted before reconciliation. UNKNOWN attempt evidence remains unknown. +- [x] Four-generation store/evaluator chains preserve same-revision failures, + immutable revision time, expiry and exact current/historical replay. +- [x] Horizon, Direction and Debt incomplete-evidence contracts and the accepted + T9 generator execute; unavailable evidence cannot become favorable zero. +- [x] Core bundle verification and example builds remain deterministic. +- [ ] Independent review of the repaired #57 head and associated admission findings. +- [ ] Maintainer resolves the stacked #53 release path and publishes v0.2.0. + +Evidence: `tests/test_ci_history_admission.py`, `tests/test_revision_history.py`, +`tests/conformance/`, core example +verification. The #57 fork matrix passed on Python 3.12/3.13/3.14 at `b849923`. +The broader B1/B7 milestone is still open; this ledger does not silently mark +that historical target achieved. + +## 0.3.0 — Evidence to Action + +- [x] Five deterministic queues, explicit priority/dependencies and bounded + acceptance; observed exact-head evidence gates merge admission. +- [x] SARIF/JUnit/Cobertura/performance evidence retains source/revision/age gaps. +- [x] Bounded handoff, cursors, conditional reads and offline replay execute. +- [x] Missing/forbidden/stale reports cannot satisfy acceptance. +- [ ] Independent release review of #59; maintainer release merge/tag. + +Evidence: `tests/test_workscope.py`, +`tests/test_work_handoff.py`, `examples/work`. The #59 fork matrix passed at +`a41fac6` on Python 3.12/3.13/3.14. + +## 0.4.0 — reproducible consumer handoff + +- [x] Receipt execution mechanics are outside canonical identity; semantic + acquisition distinctions remain identity bearing. +- [x] Exact historical verification precedes compatibility dispatch. Unknown, + mixed, ambiguous or unauthorized nonidentity edges fail closed. +- [x] Mutable branch/change bindings resolve immutable projects and commit + membership before/after source reads; packets bind exact verified bytes. +- [x] Historical work v1 and core lineages retain their own replay paths. +- [ ] Independent review of #64 and receipt/compatibility/consumer boundaries. +- [ ] Maintainer release merge/tag. + +Evidence: `tests/test_receipt_identity.py`, `tests/test_compatibility.py`, +`tests/test_work_handoff.py`, historical examples. The #64 fork matrix passed +at `c98e7ed` on Python 3.12/3.13/3.14. + +## 0.5.0 — Consumer Operations + +- [x] Adopter project/policy/engine bindings reject wrong subjects before + collection or canonical publication; disabled invocations retain negative control. +- [x] Shared request/deadline budgets remain bounded under parallel collection. +- [x] Repeated generations, immutable invocation receipts and calendar-day audit + survive private history export, verification and restore. +- [x] Packet-bound outcomes remain caller reports and grant no independent acceptance. +- [ ] Independent review of #69 and archive/caller/budget boundaries. +- [ ] Maintainer release merge/tag. + +Evidence: `tests/test_work_operations.py`, `examples/work/adoption.json`, +CI archive round trip. The #69 fork matrix passed at `aa5e0e1` on +Python 3.12/3.13/3.14. Actual scheduled outside adoption (#71), collection +qualification (#62), long-term incremental archive scale (#67) and B7 remain +explicit owner/external gates. + +## 0.6.0 — offline analysis and executable assurance + +- [x] Pinned SHA-1/SHA-256 source/tree/blob proofs, Python 3.12 AST/import analysis, + conservative finding comparison, SARIF and bounded verified source packets. +- [x] v2 authenticates reachable raw commits, all parent edges and historical + tree/blob objects; roots, window selection, edit counts and COMPLETE claims + are derived again without Git, a checkout, network or source execution. +- [x] Merge ancestry, clock skew, shallow/missing objects, graph/object/byte/work + caps and unsafe paths retain explicit partial states; incomplete roots cannot + establish comparable repository identity. +- [x] Rehashed root/ancestor/time/count/frontier/complete forgeries, wrong object + kinds and unused proof objects fail. An independent LCS oracle checks the + declared byte-line edit algorithm, including repeated/unterminated lines. +- [x] Legacy `examples/code` regenerates byte for byte. New `examples/code-v2` + and separate schemas replay independently; cross-lineage comparison is refused. +- [x] Twenty permanent conformance IDs execute real native normalization, + consumed RAW_RUNS provenance, one shared multi-Vital graph, all T8 substitutions, + canonical bundle operations and stateful HistoryStore operations. +- [x] HistoryStore publication is locked; a stale advancing writer preserves + candidates, reports conflict and rebuilds from latest. Permission failures + remain explicit HISTORY_GAP. +- [ ] Independent review of Git proof completeness, bounds and algorithm specification. +- [ ] Independent review of the concrete conformance surface and exact fixtures; + capability-level research acceptance alone does not accept this implementation. +- [ ] Independent release/admission review of the final cumulative head, including + the October findings on #75 and the included readiness fixes. +- [ ] Upstream workflow approval where GitHub requires maintainer authorization. +- [ ] Maintainer merges the reviewed feature into release/0.6.0, reconciles the + cumulative #75 release PR, then merges/tags v0.6.0 under CONTRIBUTING.md. + +Reproduce: + +```sh +python -m pytest +devostasis vectors --quiet +python scripts/check_conformance_surface.py +devostasis verify --bundle examples/sample-bundle +python scripts/make_code_examples.py +python scripts/make_code_examples.py --v2 +git diff --exit-code -- examples/code examples/code-v2 +devostasis code verify --bundle examples/code/bundle +devostasis code verify --bundle examples/code-v2/bundle +devostasis code verify-packet --bundle examples/code-v2/bundle --packet examples/code-v2/packet.json +devostasis work verify --bundle examples/code-v2/work-bundle +devostasis work replay --bundle examples/code-v2/work-bundle +python -m pip wheel --no-deps --no-build-isolation . +``` + +CI additionally verifies both work/core examples, deterministic repeated builds +and the operations archive restore on Python 3.12/3.13/3.14. Green checks establish +those executions at the recorded head; they do not complete the unchecked gates. + +B1 retains R11–R53 and independent exact-fixture acceptance. B7 retains sustained +outside-consumer evidence. Legacy v1 history and omitted blob sizes keep their +documented proof limits. No compatibility migration, core GitLab collector, +threshold retuning, aggregate health score or publication is implied by this PR. diff --git a/docs/spec/PROVENANCE.md b/docs/spec/PROVENANCE.md index 33692d3..d4ff501 100644 --- a/docs/spec/PROVENANCE.md +++ b/docs/spec/PROVENANCE.md @@ -8,7 +8,10 @@ normative here. The process is domain-neutral and documented in the | Identifier used by the code | Source unit(s) | Status when implemented | | --- | --- | --- | -| `devostasis.code-analysis.v1`, `devostasis.code-policy.v1`, `devostasis.code-engine.v1`, `devostasis.code-packet.v1`, `devostasis.code-comparison.v1` | implementation-owned E4, issue #72 | local, introduced in 0.6.0; static source evidence without accepted Vital/Instrument judgement; independent history proof is #73 | +| `devostasis.code-analysis.v1`, `devostasis.code-policy.v1`, `devostasis.code-engine.v1`, `devostasis.code-packet.v1`, `devostasis.code-comparison.v1` | implementation-owned E4, issue #72 | local, introduced in 0.6.0; static source evidence without accepted Vital/Instrument judgement; v1 history remains recorded acquisition; v2 supplies independent proof for #73 | +| `devostasis.code-analysis.v2`, `devostasis.code-policy.v2`, `devostasis.code-engine.v2`, `devostasis.code-packet.v2`, `devostasis.code-comparison.v2`, `devostasis.git-history-proof.v1` | implementation-owned #73 assurance increment | local, introduced within 0.6.0; bounded authenticated graph/object proof and independently derived edit counts; separate v1 replay preserved; independent implementation review pending | +| `devostasis.conformance-bridge.v1`, `integrity.bands.v1+ci-unit-004+hist-002+independence-001` | PV-CONFORMANCE-SURFACE-001 / PV-REV-CONFORMANCE-SURFACE-001, accepted capability contract; T3 independence premise | concrete bridge/materialization is implementation-owned and awaits independent review; optional RAW_RUNS provenance is consumed without changing canonical revision identity or default historical rule | +| `devostasis.ci-outcomes.gitlab.v1` | PV-CI-NORM-001 | local offline native status mapping; no core GitLab collector or favorable history completeness is claimed | | `devostasis.work-adoption.v1`, `devostasis.work-invocation.v1`, `devostasis.work-result.v1`, `devostasis.work-history-archive.v1`, `devostasis.work-history-audit.v1` | implementation-owned E3 operational adoption, issues #62/#65 | local, introduced in 0.5.0; separate from immutable work/core canonical members and accepted Instrument/Vital semantics; caller reports grant no independent acceptance or execution authority | | `devostasis.bundle.v3`, `PV-BUNDLE-ID-003`, `PV-RECEIPT-IDENTITY-003`, `devostasis.receipt-identity.v1`, `devostasis.observations.v2`, `devostasis.manifest.v2`, `devostasis.execution-receipt.v1`, `devostasis.render.v5` | cumulative PV-RECEIPT-IDENTITY-001/002/003 | independently accepted by PV-REV-RECEIPT-IDENTITY-003; adopted in 0.4.0 with RECEIPT-ID-01..15,17..21. Literal devostasis schema tokens are local choices; rejected ID-16 is preserved, not reassigned | | `devostasis.contract-compatibility.v1`, `devostasis.compatibility-dispatch-policy.v1` | PV-COMPAT-001/002 | independently accepted by PV-REV-COMPAT-002; adopted in 0.4.0; no accepted production nonidentity edge or migration is declared | @@ -20,7 +23,7 @@ normative here. The process is domain-neutral and documented in the | `flow.bands.v1` | PV-FLOW-EMPTY-QUEUE-001 (PV-CAL-FLOW-001) and PV-FLOW-MERGE-LATENCY-001 (PV-CAL-FLOW-PRECISION-001), both from PV-CAL-002 findings | produced with fixtures; adopted in 0.1.2; the adopted surface was found conformant by the PV-SPEC-001 passes of 2026-09-07 | | `pulse.bands.v1` | PV-PULSE-REQUIRED-LOWER-BOUND-001 (PV-CAL-PULSE-CAP-001, from PV-CAL-003) | produced with fixtures; adopted in 0.1.2; the adopted surface was found conformant by the PV-SPEC-001 passes of 2026-09-07 | | `devostasis.bundle.v2`, `PV-BUNDLE-ID-002`, `PV-EFFECTIVE-CONFIG-001`, `PV-EFFECTIVE-CONFIG-AUTHORITY-001` | PV-ARTIFACT-001..005 (B1 acyclicity, B2 config identity, B3 persisted preimage, B4 stored-config authority); v2 adds gauges.json and demand.json to the identity preimage | PV-ARTIFACT-V1-005 accepted by PV-REV-ARTIFACT-005; ART-23..ART-25 adopted in 0.1.2 | -| history store and reports | PV-REPORT-001 | accepted by PV-REV-REPORT-001 with the non-decisions resolved as documented; RPT-4..RPT-7/RPT-9 remain open implementation work | +| history store and reports | PV-REPORT-001 | accepted by PV-REV-REPORT-001 with the non-decisions resolved as documented; 0.6 adds RPT-4/5/6/8/9 operation fixtures plus publication conflict/permission handling; exact-fixture review remains pending | | `PV-BAND-ORDER-001`, `devostasis.band-order.v1` | PV-BAND-ORDER-001, which supersedes the PV-ORDER-001 research item that asked whether an order could be declared at all | accepted, adoption authorized; adopted in 0.1.8 with cases ORDER-01..15, executable as vectors. Ordering is per Vital only. PV-SPEC-001 reviewed the adoption and found the semantics conformant and the case identifiers reassigned; the repair restored ORDER-01..15 to the meanings the contract's `Required conformance cases` section gives them before 0.1.8 was tagged | | `integrity.bands.v1+ci-unit-004` | PV-REV-INTEGRITY-UNKNOWN-001 (issue #13, REPAIR REQUIRED with INT-UNKNOWN-01..06), PV-REV-TEST-003 (a PARTIAL required series is UNKNOWN), PV-REV-TEST-VECTORS-002 (sample_strength and CI_SPARSE_SAMPLE, issue #21) | accepted; adopted in 0.1.9 as one Integrity rule version. PV-INTEGRITY-TOTALITY-001 (accepted by PV-REV-INT-TOTALITY-001, INT-TOTAL-01..14; 01..06 and 08..13 executable, 07 and 14 need a RAW_RUNS path and a GitLab adapter) is part of the same unreleased version: it replaced the unresolved superset this version first derived mechanically for #12 finding 3, which the review of 2026-09-30 found could omit the band it emitted | | `direction.bands.v2` | PV-REV-DIRECTION-CLOSED-TARGET-001 (calibration finding 9, REPAIR REQUIRED with DIR-CLOSED-01..09), PV-DIRECTION-INCOMPLETE-001 (accepted by PV-REV-DIRECTION-INCOMPLETE-001, DIR-INCOMPLETE-01..16) | accepted; adopted in 0.2.0 as one Direction rule version: state-neutral linkage, LINKED/UNLINKED/UNRESOLVED per change request and the completion-set rule; the open-target count stays derived for presentation only. The acceptance of PV-DIRECTION-INCOMPLETE-001 narrows DIR-CLOSED-04: a positively missing target is unlinked, an unresolvable one unresolved; unreadable linkage evidence no longer fails the project | @@ -32,7 +35,7 @@ normative here. The process is domain-neutral and documented in the | `PV-REV-ACTIVITY-COVERAGE-001` | the judgement on issue #9 | accepted reading 3, runtime unchanged since 0.1.7; ACT-COV-01..05 adopted in 0.1.9 as `activity` vectors | | `clutter.bands.v1`, `PV-CLUTTER-INCOMPLETE-001` | PV-CLUTTER-INCOMPLETE-001 (accepted by PV-REV-CLUTTER-INCOMPLETE-001 with CLU-INCOMPLETE-01..20), PV-ISSUE-026-RECONCILE-001 (the reading of issue #26 under that contract), PV-REV-PR-031-003 (a PARTIAL count proves a floor only with machine-verifiable subset semantics, CLU-PARTIAL-TRUST-01..08) | accepted; adopted in 0.1.9 as one Clutter rule version with the twenty cases executable; the T7 upper-bound path moved under the same rule id; the subset proof (`value_semantics = OBSERVED_SUBSET_COUNT` in a PARTIAL count's coverage) is part of the same rule version, which 0.1.9 introduces and no stored bundle carries yet | | exact vector families T2/R1/R2, R3, R4, T4/T5/T7 | PV-TEST-VECTORS-002/005/004/007, accepted by PV-REV-TEST-VECTORS-002/005/004/007 | transcribed into `tests/vectors/` in 0.1.9 as the accepted expectations; T5 (`PULSE_ISSUE_ONLY_ACTIVITY`) adopted under `pulse.bands.v1` as the accepted vector requires; T7 (`retention_semantics = UNCLASSIFIED` upper bound) adopted under `clutter.bands.v1`, because the Clutter rule had to move for PV-CLUTTER-INCOMPLETE-001 in the same release and the accepted T7 semantics are unchanged | -| `devostasis.vectors.v1` | implementation choice; the research side produced PV-TEST-001 (judged CHANGES_REQUIRED by PV-REV-TEST-001), PV-TEST-002/003 and the final T9 reconciliation PV-TEST-004 (accepted by PV-REV-TEST-004), and delivers exact vectors one family per PV-TEST-VECTORS-00n unit | local: the format and runner are this repository's, so accepted vectors arrive executable rather than needing translation; 0.1.9 adds the `ci` and `activity` kinds and `variants` in answer to the format findings of issues #20 and #23; PV-REV-CONFORMANCE-SURFACE-001 accepted a successor capability contract for the families v1 cannot carry (T3, T6, the bundle and store cases), not yet built here | +| `devostasis.vectors.v1` | implementation choice; the research side produced PV-TEST-001 (judged CHANGES_REQUIRED by PV-REV-TEST-001), PV-TEST-002/003 and the final T9 reconciliation PV-TEST-004 (accepted by PV-REV-TEST-004), and delivers exact vectors one family per PV-TEST-VECTORS-00n unit | local: the format and runner are this repository's, so accepted vectors arrive executable rather than needing translation; 0.1.9 adds the `ci` and `activity` kinds and `variants` in answer to the format findings of issues #20 and #23; PV-REV-CONFORMANCE-SURFACE-001 accepted a successor capability contract for the families v1 cannot carry (T3, T6, the bundle and store cases), implemented as the source-checkout operation bridge in 0.6; concrete-surface and exact-fixture independent review remain pending | | `devostasis.gauge.v1` | implementation-defined normalization submitted for judgement | accepted by PV-REV-GAUGE-001 for presentation and same-Vital ordering; never cross-Vital, never aggregated | | `devostasis.demand.v2` | PV-ROLE-001 (minimal consumer demand interface); v1 defined by the implementation, v2 removes the cross-Vital gauge tie-break required by ROLE-01 | default table, UNRESOLVED semantics and aggregate-free projection accepted by PV-ROLE-001; the v2 repair was found conformant by PV-SPEC-001; predictive validity under calibration in PV-CAL-004, whose measurement the owner reassigned to the implementation side | | `devostasis.fleet.v1` | implementation-defined projection submitted for judgement | accepted by PV-REV-FLEET-001 (FLEET-01..10): `aggregate` and `cross_project_order` stay exactly null, no accepted contract defines a cross-project order | diff --git a/docs/spec/README.md b/docs/spec/README.md index b51a2c8..4186ee7 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -17,6 +17,7 @@ conformance tests. | [registers.md](registers.md) | Targets and debt register files, the change-request link marker | | [github-adapter.md](github-adapter.md) | What the GitHub adapter collects, how it maps failures and caps, what it does not collect | | [conformance.md](conformance.md) | Conformance case identifiers and where each is implemented | +| [conformance-surface.md](conformance-surface.md) | Versioned offline native normalization, multi-Vital, canonical bundle and HistoryStore operation bridge | | [vectors.md](vectors.md) | The executable conformance vector format and its runner | | [work-scope.md](work-scope.md) | Separate consumer work-scope v1, five queues, replay, history and handoff | | [work-evidence.md](work-evidence.md) | Optional revision-bound SARIF, JUnit, Cobertura and performance profiles | diff --git a/docs/spec/conformance-surface.md b/docs/spec/conformance-surface.md new file mode 100644 index 0000000..060880b --- /dev/null +++ b/docs/spec/conformance-surface.md @@ -0,0 +1,71 @@ +# Executable operation conformance bridge + +`devostasis.conformance-bridge.v1` is a source-checkout bridge implementing the +capabilities of `devostasis.executable-conformance-surface.v1`, accepted by +PV-REV-CONFORMANCE-SURFACE-001. Capability acceptance does not independently +accept this implementation or its exact fixture materialization. Those reviews +remain pending. Existing `devostasis.vectors.v1` files retain their meaning. + +Run offline, without provider APIs, secrets or private history: + +```sh +python scripts/check_conformance_surface.py +python -m pytest tests/test_conformance_surface.py +``` + +The closed file envelope is `schema`, `source`, `cases`. Each case has exactly +`case`, `scenario`, `given`, `checks`. The published shape is +`schemas/conformance-bridge.schema.json`; execution additionally validates +operation inputs, unique IDs across both carriers and exact recorded lineage. +The bridge and self-contained drivers are in `tests/surface_bridge.py`; +literal inputs and assertions are in `tests/fixtures/conformance-surface.json`. +No arbitrary module, executable, test-name pointer or evaluated code is admitted. + +Each check contains a JSON path array and exactly one of `equals`, `not_equals`, +`contains`, `absent: true`. Equality compares canonical JSON bytes, preserving +scalar types. A positive check of an unknown path fails. Unknown operations, +fields, assertions or lineages fail; none is a passing skip. Every declared +check executes. T8 requires all five independent status/freshness substitutions; +R9 keeps both provider variants under one permanent ID. Failure output names +the permanent case and checkpoint path, including its subordinate variant. + +| Scenario version | Executed boundary | +| --- | --- | +| `ci-native.v1` | GitHub native status/conclusion or GitLab native status through exact `devostasis.ci-outcomes.github.v1` / `devostasis.ci-outcomes.gitlab.v1`, then revision composition; outcome and contribution assertions run before any Vital interpretation | +| `independence.v1` | real Actions attempts → one immutable revision → governed Integrity consuming positive `ci.sample_semantics=RAW_RUNS`; history failure remains sticky and retry count cannot establish independent samples | +| `multi-vital.v1` | one explicit shared commit/change-request/issue/branch inventory graph → normal derivation → seven-Vital snapshot; dependency provenance and absent aggregate assertions inspect the same execution | +| `bundle.v1` | persisted canonical core bundle, canonical byte/digest production, member access failure, receipt binding, public-only verification, private/convenience exclusion, immutable collision | +| `history-store.v1` | real isolated FilesystemHistoryStore, idempotent put, failed commit, missing predecessor, permission-domain failure and deterministic two-writer checkpoints/retry | + +Bundle/store scenarios pin `devostasis.manifest.v2`, `devostasis.bundle.v3`, +`PV-BUNDLE-ID-003` and `PV-RECEIPT-IDENTITY-003`. They do not use a compatibility +registry to verify exact source content. Changing that tuple requires a new +scenario version. Provider maps are explicit fixture discriminators; an unknown +or mixed provider/map pair is refused. + +The GitLab surface is a pure offline outcome normalizer, not the planned core +GitLab collector. It establishes no pipeline-history completeness. R5–R10 here +materialize the native normalization/contribution boundary; independent review +of downstream exact fixture obligations remains separate. The bridge does not +claim all of R11–R53 or INT-TOTAL-14 from those six cases. + +T3 introduces the opt-in rule +`integrity.bands.v1+ci-unit-004+hist-002+independence-001`. Absent provenance +uses the exact historical rule. Positive RAW_RUNS keeps one revision contribution +and reports `sample_strength=PROVISIONAL_NONINDEPENDENT`; an AVAILABLE result is +reduced to DEGRADED. Unknown/stale/unsupported provenance yields UNKNOWN without +a band. No threshold or window changes. The rule boundary is visible to delta. + +The store uses a nonblocking OS publication lock. A stale advancing writer +preserves its immutable candidate and raises `HISTORY_WRITE_CONFLICT`; it must +rebuild from latest before retrying. Identical indexed writes remain idempotent. +The RPT-9 scenario forces both readers to observe one predecessor, refuses an +overlapping lock holder, commits writer A, refuses stale writer B, inspects +latest and all immutable candidates, then rebuilds and commits B. Index access +denial remains `HISTORY_STORE_FORBIDDEN`, hence HISTORY_GAP, never BASELINE. + +Twenty permanent IDs are executable here: R5–R10, T3, T6, T8, +ART-05/08/09/10/11/15 and RPT-4/5/6/8/9. Nineteen reduce the prior list of 62 +unmaterialized cases; RPT-8 additionally gains an operation fixture. The remaining +43 are R11–R53. B1 stays open, as do independent concrete-surface/exact-fixture +acceptance, T9 cell-key delivery, core GitLab and external B7 evidence. diff --git a/docs/spec/conformance.md b/docs/spec/conformance.md index 98e78b8..f03375d 100644 --- a/docs/spec/conformance.md +++ b/docs/spec/conformance.md @@ -547,18 +547,46 @@ requirements without assigning new research case identifiers. | rejected | a case expecting refused evidence passes only when the evaluation raises with that code, and the refusal is its whole expectation | `test_a_rejected_case_passes_only_when_the_evidence_is_refused_with_that_code`, `test_rejected_is_the_whole_expectation_and_names_a_code` | | activity kind | the interval and the coverage notes of the activity member are checked | `test_the_activity_kind_checks_the_interval_and_the_coverage_it_discloses` | -Cases not yet implemented as tests (T3, T6, T8, R5..R53, ART-05, -ART-08..ART-11, ART-15, RPT-4..RPT-6, RPT-9) are listed in the ROADMAP under the -synthetic fixture suite; T9 is implemented by its generator since 0.2.0, and -its cell-key vectors are still the research process's to deliver. The research process delivers them as executable JSON -vectors in the format of [vectors.md](vectors.md), one accepted family per -unit; the ones accepted so far (T2, R1, R2, R3, R4, T4, T5, T7) are in the -corpus above, and so are the twenty cases of `PV-CLUTTER-INCOMPLETE-001`, -transcribed from the accepted contract the way `ORDER-01..15` were. The `ci` kind reaches the normalization boundary R5..R10 are -about, so that family can be materialized against it (issue #20); `variants` -and the dependency-group assertions close the T8 and part of the T6 gap of -issue #23, while T3 and the bundle and store cases still need a surface this -version does not publish. +The successor operation bridge is documented in +[conformance-surface.md](conformance-surface.md). It executes implementation +materializations under the accepted capability contract, with exact-fixture and +concrete-surface independent review still pending. B1 remains open: R11..R53 +still need exact executable materialization; T9 cell-key delivery remains separate. +The existing v1 vector corpus and all permanent identifiers retain their meaning. + +## Versioned operation bridge (0.6 implementation materialization) + +Each citation resolves to one case in the closed bridge carrier. Native R5..R10 +assert normalization and revision contributions; full downstream fixture fidelity +is an independent review obligation, not inferred from these checks. + +| Case | Executed obligation | Proof | +| --- | --- | --- | +| R5 | native GitHub skipped is NOT_EXECUTED, no decisive/failed contribution | `bridge:R5` | +| R6 | native GitLab manual is NOT_EXECUTED, no decisive/failed contribution | `bridge:R6` | +| R7 | native timed_out is VERIFY_FAIL, one failed decisive contribution | `bridge:R7` | +| R8 | native cancelled is non-verification terminal | `bridge:R8` | +| R9 | same-ID GitHub failure/GitLab failed normalization equivalence | `bridge:R9` | +| R10 | future provider values are UNKNOWN without guessed contribution | `bridge:R10` | +| T3 | one immutable revision, failed execution then ten successful RAW_RUNS retries stays DEGRADED / PROVISIONAL_NONINDEPENDENT | `bridge:T3` | +| T6 | shared inventories feed Pulse/Flow/Clutter; shared dependency provenance and no aggregate fields | `bridge:T6` | +| T8 | independent FORBIDDEN, UNKNOWN, ERROR, PARTIAL and STALE numeric substitutions under one ID | `bridge:T8` | +| ART-05 | required member access denial cannot verify | `bridge:ART-05` | +| ART-08 | canonical map/whitespace invariance; material scalar/type differences change digest | `bridge:ART-08` | +| ART-09 | provider observation references resolve in identity-bound receipts; missing receipt binding fails | `bridge:ART-09` | +| ART-10 | persisted public members verify/render without network/private dependencies | `bridge:ART-10` | +| ART-11 | private/SNAP convenience evidence is outside public identity | `bridge:ART-11` | +| ART-15 | same-ID differing canonical bytes collide without overwrite | `bridge:ART-15` | +| RPT-4 | actual identical put is idempotent, differing put collides | `bridge:RPT-4` | +| RPT-5 | incomplete immutable commit preserves prior latest | `bridge:RPT-5` | +| RPT-6 | missing predecessor yields HISTORY_GAP, retains snapshot, invents no transition | `bridge:RPT-6` | +| RPT-8 | store FORBIDDEN remains explicit HISTORY_GAP | `bridge:RPT-8` | +| RPT-9 | deterministic two-reader/write conflict, OS lock, preserved candidates and rebuilt retry | `bridge:RPT-9` | + +Harness proof: `test_bridge_unknown_and_duplicate_lineage_never_skips`, +`test_bridge_consumes_negative_assertions_and_wrong_expectations`, +`test_unknown_input_and_assertion_paths_cannot_pass`, +`test_t8_cannot_drop_a_required_substitution`. ## Companion regression evidence (implementation-owned) @@ -647,3 +675,16 @@ not close B1's missing accepted exact fixtures or B7's sustained calibration. | DEV-PAGES-02 | changed total between pages is partial | `test_work_pagination_changed_total_between_pages_is_partial` | | DEV-PAGES-03 | valid exact full empty and header absent responses are complete | `test_work_pagination_valid_exact_full_empty_and_header_absent_responses_are_complete` | | DEV-PAGES-04 | github result caps and invalid totals are not complete | `test_work_pagination_github_result_caps_and_invalid_totals_are_not_complete` | + +## Independent Git history proof (0.6 implementation-local) + +| Boundary | Proof | +| --- | --- | +| authenticated ancestry/root/count/time/frontier/completeness | `test_rehashed_history_claims_require_authenticated_objects` | +| offline replay without Git/subprocess | `test_offline_replay_has_no_git_repository_or_subprocess_dependency` | +| all merge parents and clock-skew traversal | `test_graph_walk_does_not_prune_clock_skew_or_merge_parents` | +| bounded partial proof never promotes COMPLETE | `test_bounded_proof_never_claims_complete_history` | +| object kind/reachability | `test_wrongly_typed_historic_objects_fail_closed`, `test_valid_but_unreachable_historic_object_is_rejected` | +| v1/v2 replay and comparison boundary | `test_v1_and_v2_replay_separately_and_do_not_compare_across_lineage`, `test_v2_frozen_example_replays_and_has_explicit_schemas` | +| independent edit oracle | `test_byte_line_algorithm_matches_independent_lcs_oracle` | +| exclusion applies before historical blob reads | `test_historic_proof_does_not_read_policy_excluded_blob_bytes` | diff --git a/docs/spec/history-and-reports.md b/docs/spec/history-and-reports.md index ace4508..4d828a2 100644 --- a/docs/spec/history-and-reports.md +++ b/docs/spec/history-and-reports.md @@ -281,3 +281,15 @@ non-monotonic observation boundary; the immutable authority of the comparison; ACT-COV-01..05 as `activity` vectors. RPT-4..RPT-6 and RPT-9, including an executable permission-domain fixture, remain open implementation work named by PV-REV-REPORT-001. + +## Publication conflict and permission checkpoint (0.6) + +Canonical commit uses a nonblocking OS lock across store publication, including +rename, immutable put, index and latest copy. A second publisher gets explicit +HISTORY_WRITE_CONFLICT. Before an advancing non-duplicate candidate reaches the +index, its previous_bundle_id must equal the current indexed tail. A stale writer +keeps its immutable candidate and must rebuild against latest; no silent overwrite +or favorable history replacement occurs. Indexed duplicates remain idempotent. +Failure before immutable commit leaves the prior latest authoritative. Index +PermissionError is HISTORY_STORE_FORBIDDEN, never absence/BASELINE. See the +versioned RPT operation scenarios in [conformance-surface.md](conformance-surface.md). diff --git a/docs/spec/integrity-ci.md b/docs/spec/integrity-ci.md index 3873595..417f0c7 100644 --- a/docs/spec/integrity-ci.md +++ b/docs/spec/integrity-ci.md @@ -217,3 +217,14 @@ verification degrades instead of claiming `CLEAN`; INT-UNKNOWN-01..06; T2, R1 and R2 as vectors ([conformance.md](conformance.md)). The `ci` vector kind ([vectors.md](vectors.md)) executes a case at the normalization boundary this page describes. + +## Offline GitLab normalization and independence (0.6) + +`devostasis.ci-outcomes.gitlab.v1` is a pure native status map: success→VERIFY_PASS, +failed→VERIFY_FAIL, canceled→NON_VERIFY_TERMINAL, skipped/manual/scheduled→NOT_EXECUTED. +Created/waiting_for_resource/preparing/waiting_for_callback/pending/running/canceling +are VERIFY_UNRESOLVED. Every other value is UNKNOWN. This boundary is available +to offline conformance without claiming a core GitLab collector or complete +pipeline retry history. The explicit provider/map pair is checked before use. +The optional governed RAW_RUNS rule and its exact compatibility boundary are in +[vitals.md](vitals.md) and [conformance-surface.md](conformance-surface.md). diff --git a/docs/spec/observations.md b/docs/spec/observations.md index 95f49dc..6c17d0e 100644 --- a/docs/spec/observations.md +++ b/docs/spec/observations.md @@ -156,3 +156,11 @@ receipt is identity-bearing. Enabling a cache therefore never moves a C1 observed zero, C2 tier limitation, C3 permission denial, C4 truncated history, C5 stale cache, C6 connector ambiguity, C7 transient failure. See [conformance.md](conformance.md). + +## Optional sample-independence input (0.6) + +`ci.sample_semantics` is a RAW-OBS-V0 enum, RAW_RUNS or INDEPENDENT_REVISIONS. +The governed Integrity extension consumes its status/freshness/value and adds +it to input metadata. It never creates extra canonical revision records. +Absent input retains historical evaluation. Unknown/stale/unsupported values +fail closed under the versioned rule described in [vitals.md](vitals.md). diff --git a/docs/spec/vitals.md b/docs/spec/vitals.md index b1912c5..be2a478 100644 --- a/docs/spec/vitals.md +++ b/docs/spec/vitals.md @@ -549,3 +549,14 @@ step towards combining them. | Flow congested | 14 days, 10 open, median over 168 h = 604800 s | | Flow gridlocked | 3 open for 30 days with 0 merged, or 10 open with median over 336 h = 1209600 s | | Clutter heavy / cluttered | 25 / 5 stale items, ratio 1/2 / 1/4 with 4 tracked, 20 / 6 stale branches | + +## Optional Integrity independence provenance (0.6) + +`ci.sample_semantics` selects the opt-in rule +`integrity.bands.v1+ci-unit-004+hist-002+independence-001`. It is an enum: +RAW_RUNS or INDEPENDENT_REVISIONS. Absent input preserves the exact historical +rule and bytes. Present unknown/stale/unsupported input yields UNKNOWN without +a band. Positive RAW_RUNS cannot be AVAILABLE: it retains failure-sticky +one-revision arithmetic, reports DEGRADED when a band is otherwise available, +and sample_strength PROVISIONAL_NONINDEPENDENT. Existing UNKNOWN precedence, +thresholds/windows and per-Vital comparison boundaries remain intact. diff --git a/tests/test_spec_drift.py b/tests/test_spec_drift.py index 12d39c1..1f75b07 100644 --- a/tests/test_spec_drift.py +++ b/tests/test_spec_drift.py @@ -16,6 +16,7 @@ CITATION = re.compile(r"`(test_[a-z0-9_]*\*?)`") DEFINITION = re.compile(r"^def (test_[a-z0-9_]+)", re.MULTILINE) VECTOR_CITATION = re.compile(r"`vector:([A-Z0-9-]+)`") +BRIDGE_CITATION = re.compile(r"`bridge:([A-Z0-9-]+)`") def _defined_tests() -> set[str]: @@ -59,6 +60,13 @@ def test_the_specification_index_lists_every_specification_page(): assert not missing, f"docs/spec/README.md does not link: {missing}" +def test_the_conformance_table_and_bridge_name_the_same_cases(): + from surface_bridge import load + corpus = {case["case"] for case in load(ROOT / "tests/fixtures/conformance-surface.json")} + cited = set(BRIDGE_CITATION.findall(CONFORMANCE.read_text(encoding="utf-8"))) + assert cited == corpus + + def test_every_contract_identifier_the_code_declares_is_documented(): """A version the runtime writes into a bundle must be findable in the specification.""" from devostasis import contracts From 354429ff78033a8fbd22f0cd665d82c839a52144 Mon Sep 17 00:00:00 2001 From: Unisphere Dev Date: Wed, 21 Oct 2026 16:19:21 +0300 Subject: [PATCH 32/33] docs(release): use the declared isolated wheel build --- docs/release-acceptance.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-acceptance.md b/docs/release-acceptance.md index c27ab6d..43329d1 100644 --- a/docs/release-acceptance.md +++ b/docs/release-acceptance.md @@ -120,7 +120,7 @@ devostasis code verify --bundle examples/code-v2/bundle devostasis code verify-packet --bundle examples/code-v2/bundle --packet examples/code-v2/packet.json devostasis work verify --bundle examples/code-v2/work-bundle devostasis work replay --bundle examples/code-v2/work-bundle -python -m pip wheel --no-deps --no-build-isolation . +python -m pip wheel --no-deps . ``` CI additionally verifies both work/core examples, deterministic repeated builds From a4761daa9fa87026ea106a3d722e7299b3de3b8b Mon Sep 17 00:00:00 2001 From: Unisphere Dev Date: Wed, 21 Oct 2026 17:29:10 +0300 Subject: [PATCH 33/33] feat(ci): complete R11-R53 native and durable history conformance --- .devostasis/debt.json | 5 +- CHANGELOG.md | 8 +- ROADMAP.md | 71 +- docs/release-acceptance.md | 14 +- docs/spec/PROVENANCE.md | 1 + docs/spec/conformance-surface.md | 35 +- docs/spec/conformance.md | 68 +- docs/spec/integrity-ci.md | 66 +- docs/spec/observations.md | 11 + docs/spec/vitals.md | 14 + pyproject.toml | 2 +- schemas/ci-normalization.schema.json | 778 + schemas/conformance-bridge.schema.json | 345 + scripts/check_conformance_surface.py | 4 +- src/devostasis/adapters/ci.py | 319 + src/devostasis/vitals/integrity.py | 13 +- tests/ci_surface_bridge.py | 64 + tests/fixtures/ci-revisions.json | 17569 +++++++++++++++++++++++ tests/surface_bridge.py | 29 +- tests/test_ci_normalization.py | 157 + tests/test_ci_surface_schema.py | 47 + tests/test_conformance_surface.py | 9 +- tests/test_spec_drift.py | 4 +- 23 files changed, 19558 insertions(+), 75 deletions(-) create mode 100644 schemas/ci-normalization.schema.json create mode 100644 src/devostasis/adapters/ci.py create mode 100644 tests/ci_surface_bridge.py create mode 100644 tests/fixtures/ci-revisions.json create mode 100644 tests/test_ci_normalization.py create mode 100644 tests/test_ci_surface_schema.py diff --git a/.devostasis/debt.json b/.devostasis/debt.json index 7c5587e..aa5a005 100644 --- a/.devostasis/debt.json +++ b/.devostasis/debt.json @@ -47,10 +47,11 @@ { "id": "D-1", "title": "Conformance cases named in the specification have no executable fixture", - "state": "open", + "state": "closed", "opened": "2026-09-05", "updated": "2026-10-21", - "cost": "0.6 publishes a versioned operation bridge with 20 permanent implementation fixtures: R5-R10 native normalization, T3/T6/T8, ART-05/08/09/10/11/15 and RPT-4/5/6/8/9. Nineteen address the former list of 62; R11-R53 (43 cases) remain without exact materialization. Concrete surface and whole-case exact-fixture independent review remain pending, including downstream R5-R10 fidelity. B1 is open; capability acceptance is not fixture acceptance. T9 cell-key delivery remains separate." + "cost": "Closed as an implementation-fixture obligation in pending 0.6: the versioned bridge executes 63 permanent cases, materializing the former 62-case gap plus RPT-8. R11-R53 add 43 cases and 84 CI variants with native normalization and real governed/store/bundle lifecycles. B1 remains open for independent concrete-surface/whole-case exact-fixture acceptance and separate T9 cell-key delivery; capability or research expansion acceptance does not accept these local fixtures. No independent review is claimed.", + "closed": "2026-10-21" }, { "id": "D-2", diff --git a/CHANGELOG.md b/CHANGELOG.md index 36fc0a8..4eb9561 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,15 @@ Offline Repository Analysis and executable assurance, targets E4/B1, issues #72/ history proof; offline replay derives roots, window membership, edit counts and completeness. Merge ancestry, clock skew, shallow/missing objects and resource caps remain explicit. V1 bundle/packet/example bytes retain exact replay. -- Publish a versioned source-checkout conformance operation bridge with 20 permanent +- Publish a versioned source-checkout conformance operation bridge with 63 permanent implementation fixtures, consumed RAW_RUNS provenance, shared multi-Vital evidence, all T8 substitutions, canonical bundle and actual HistoryStore checkpoints. +- Materialize R11–R53 (43 permanent cases, 84 CI variants) with native parent + identities, explicit rerun proof, exact Actions/suite overlap, unresolved current + composition and failure-sticky store lifecycles. Versioned opt-in normalization + replays raw facts before governed Integrity; altered/partial evidence fails + closed and old observation/bundle replay remains unchanged. Public schemas + expose seven bridge scenarios and the bounded native input contract. - Serialize canonical store publication and reject stale advancing predecessors with explicit retry conflict while preserving immutable candidates. Store index permission denial remains FORBIDDEN/HISTORY_GAP. diff --git a/ROADMAP.md b/ROADMAP.md index 7e8bfab..fcf7620 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,8 +7,11 @@ started and work that cannot start. ## Release state and next steps — 2026-10-21 The project is at **release stabilization**, with Phase A closed and Phase B -five of seven items implemented. B1 now has 20 operation-bridge implementation fixtures (19 from the former -62-case gap); R11–R53 and independent whole-case fixture acceptance remain open, and B7 still needs sustained outside-consumer evidence. The latest +five of seven items implemented. B1 now has 63 operation-bridge implementation +fixtures, materializing the former 62-case gap and RPT-8. R11–R53 contribute +43 permanent cases with 84 CI variants. Independent whole-case/concrete-surface +acceptance and separate T9 cell-key delivery remain open; B7 still needs +sustained outside-consumer evidence. The latest published version is **v0.1.9**; 0.2.0 through 0.6.0 remain unreleased. The audit began with a clean working tree. All local and fetched remote heads @@ -145,7 +148,7 @@ start that matrix automatically in the fork. | Release the implemented increments | #57 into upstream `release/0.2.0`, then #53, #59, #64 and #69 into `master`, with each release tag | Maintainer workflow approval, independent review and green checks on each final head; carried maintenance PRs #36/#37/#50 are reconciled when their commits land | | Activate the selected outside consumer | Install a reviewed exact pin, project/policy binding, protected runner, durable writer and regular observation schedule | Actual private deployment receipts and verified stored generations; a recipe or shadow probe alone does not complete B7 | | Qualify useful repeated operation | Complete large-project GitLab collection (#62), recover private fleet observation (#32), and collect independent consumer feedback | Repeated complete inventories, explicit partial/failure recovery, real calendar-day history and one completed bounded task with original acceptance evidence | -| Close the remaining assurance gaps | B1 exact conformance (#20/#23), timestamp/lineage/store audit families (#35), and GitLab exact-head approvals where needed (#58) | Accepted fixtures at the named boundary and explicit capability qualification; 62 named conformance cases remain without executable proof | +| Close the remaining assurance gaps | B1 exact conformance (#20/#23), timestamp/lineage/store audit families (#35), and GitLab exact-head approvals where needed (#58) | 63 permanent operation cases now execute; independent exact-fixture/concrete-surface acceptance and T9 cell-key delivery remain | | Extend reach after the gates | Core GitLab Vital adapter and accepted TestState/Coverage/Deployment/Work Instrument carrier | B7 and compatibility gates, selected adopter needs, preserved historical replay; the existing GitLab work collector is a separate companion | **The highest-impact next major increment is operational adoption and a @@ -435,35 +438,27 @@ share for Direction, and its own attention order is actionable. ## Phase B: make it trustworthy over time -### B1. Executable conformance vectors — the format is built, the vectors are owed - -The specification names **62 conformance cases with no test behind them** -(T3, T6, T8, R5..R53, ART-05, ART-08..ART-11, ART-15, RPT-4..RPT-6, RPT-9). -It named 70 until 0.1.9 adopted the seven exact vectors the research process -has accepted so far (`T2`, `R1`, `R2`, `R3`, `R4`, `T4`, `T5`, `T7`), and 63 -until 0.2.0 held T9 to its accepted table with a generator; the rest are -still the research process's to produce, one accepted family per unit. - -The half that was ours shipped in 0.1.8: `devostasis.vectors.v1`, a runner, a -`devostasis vectors` command and a published schema -([vectors.md](docs/spec/vectors.md)). A case is a JSON document that states -evidence and expected result; the `vital` kind evaluates one Vital over raw -observation envelopes, the `delta` kind compares two snapshots. It fails -closed, so an unknown kind or a malformed vector is a red build rather than a -case that silently did not run, and `ORDER-01..15` are already carried that -way. 0.1.9 added the `ci` kind, which reaches the provider-native -normalization `R5..R10` are about ([#20](https://github.com/drevendev/Devostasis/issues/20)), -the `activity` kind for `ACT-COV-01..05`, and `variants`, the one-identifier -multi-variant shape `T8` and `R9` need -([#23](https://github.com/drevendev/Devostasis/issues/23)). Two more kinds -remain deliberately absent until a case needs them: bundle-level identity -cases (`ART-*`) and store cases (`RPT-4..RPT-6`, `RPT-9`) that need a fixture -store rather than a snapshot pair; `T3` needs a surface that carries -`RAW_RUNS` provenance into Integrity, which no accepted contract defines yet. - -Closing B1 needs the vectors themselves. Until then debt D-1 stays open, and -the specification keeps saying that "conformance" covers about half of what it -names. +### B1. Executable conformance — implementation complete, independent acceptance pending + +The former 62 named cases without executable materialization now run in the +0.6 source-checkout operation bridge, with RPT-8 additionally exercised. The +legacy v1 corpus remains unchanged at 148 CLI vectors. The bridge adds 63 +permanent cases across seven scenario versions; R11–R53 supply 43 cases and +84 CI variants, with literal expectations and real evaluator/store/bundle +execution. See [conformance surface](docs/spec/conformance-surface.md) and the +[case-to-proof table](docs/spec/conformance.md). + +The closed native CI projection admits exact parent identities/overlap/rerun +links, current unresolved precedence, immutable commit ordering and durable +failure history. Raw child fallback and incomplete parent-level histories +remain explicitly degraded/unknown. New observation/rule inputs are opt in; +historical bundles replay under their original semantics. + +D-1's missing-implementation-fixture obligation is closed. B1 itself remains +open for independent review of the concrete surface and whole-case fixture +fidelity, plus separate T9 cell-key delivery. Accepted capability and research +case expansion do not independently accept this implementation. No independent +review or release acceptance is claimed by the implementer. ### B7. First outside repository integrates self-observation — the owner's move @@ -477,8 +472,8 @@ templates and local proofs do not substitute for them. ### Also open from the reporting review -`RPT-4..RPT-6` and `RPT-9` as executable cases, including a permission-domain -fixture for the store. Ours, and covered by B1's format. +`RPT-4..RPT-6` and `RPT-9` now execute real store operations, including a +permission-domain fixture. Their independent exact-fixture review remains open. ### Done @@ -640,14 +635,16 @@ evidence and could make the study look ready while it holds about one day of real signal. The study must count calendar days, not bundles, and manual triggering should stop now that the release chain is quiet. -**Half of the named conformance surface is unproven.** 79 table rows cite a -test; 70 named cases have none. The specification says so and the roadmap +**Historical conformance finding (before the 0.6 materialization).** The audit +then found 79 table rows citing a +test and 70 named cases without one. The specification said so and the roadmap admits it, so nothing is being hidden, but "conformance" currently covers about half of what it names. This is debt D-1 and target B1. Since 0.1.8 the missing half is missing evidence, not missing machinery: a case can be written as a vector and executed, and the fifteen ordering cases are carried that way. Building the runner did not prove one of the 70, and counting it as progress on -D-1 would be counting the tooling as the test. +D-1 would be counting the tooling as the test. The 0.6 bridge now supplies +the implementation fixtures; independent whole-case acceptance remains open. **Provider neutrality is a design intent, not a demonstrated property.** Every provider-neutral contract has exactly one implemented provider. The GitLab diff --git a/docs/release-acceptance.md b/docs/release-acceptance.md index 43329d1..2f2fd9f 100644 --- a/docs/release-acceptance.md +++ b/docs/release-acceptance.md @@ -90,9 +90,17 @@ explicit owner/external gates. declared byte-line edit algorithm, including repeated/unterminated lines. - [x] Legacy `examples/code` regenerates byte for byte. New `examples/code-v2` and separate schemas replay independently; cross-lineage comparison is refused. -- [x] Twenty permanent conformance IDs execute real native normalization, +- [x] Sixty-three permanent conformance IDs execute real native normalization, consumed RAW_RUNS provenance, one shared multi-Vital graph, all T8 substitutions, canonical bundle operations and stateful HistoryStore operations. +- [x] R11–R53 are materialized under their 43 permanent IDs, with 84 CI variants: + native parent identities, exact overlap, proved reruns, unresolved precedence, + commit-time order/window and failure-sticky real-store lifecycles. Raw child + fallback and parent-level history limits remain explicit. +- [x] Governed Integrity recomputes native evidence/provenance/records and rejects + altered bindings; unknown lineage, contradictory identity, broken pagination + and incomplete favorable history fail closed. New behavior is opt in under + a separate rule; old bundle examples and legacy paths retain exact replay. - [x] HistoryStore publication is locked; a stale advancing writer preserves candidates, reports conflict and rebuilds from latest. Permission failures remain explicit HISTORY_GAP. @@ -127,7 +135,9 @@ CI additionally verifies both work/core examples, deterministic repeated builds and the operations archive restore on Python 3.12/3.13/3.14. Green checks establish those executions at the recorded head; they do not complete the unchecked gates. -B1 retains R11–R53 and independent exact-fixture acceptance. B7 retains sustained +The former 62-case implementation gap is materialized; B1 retains independent +concrete-surface/whole-case exact-fixture acceptance and separate T9 cell-key +delivery. B7 retains sustained outside-consumer evidence. Legacy v1 history and omitted blob sizes keep their documented proof limits. No compatibility migration, core GitLab collector, threshold retuning, aggregate health score or publication is implied by this PR. diff --git a/docs/spec/PROVENANCE.md b/docs/spec/PROVENANCE.md index d4ff501..f3f6c6e 100644 --- a/docs/spec/PROVENANCE.md +++ b/docs/spec/PROVENANCE.md @@ -11,6 +11,7 @@ normative here. The process is domain-neutral and documented in the | `devostasis.code-analysis.v1`, `devostasis.code-policy.v1`, `devostasis.code-engine.v1`, `devostasis.code-packet.v1`, `devostasis.code-comparison.v1` | implementation-owned E4, issue #72 | local, introduced in 0.6.0; static source evidence without accepted Vital/Instrument judgement; v1 history remains recorded acquisition; v2 supplies independent proof for #73 | | `devostasis.code-analysis.v2`, `devostasis.code-policy.v2`, `devostasis.code-engine.v2`, `devostasis.code-packet.v2`, `devostasis.code-comparison.v2`, `devostasis.git-history-proof.v1` | implementation-owned #73 assurance increment | local, introduced within 0.6.0; bounded authenticated graph/object proof and independently derived edit counts; separate v1 replay preserved; independent implementation review pending | | `devostasis.conformance-bridge.v1`, `integrity.bands.v1+ci-unit-004+hist-002+independence-001` | PV-CONFORMANCE-SURFACE-001 / PV-REV-CONFORMANCE-SURFACE-001, accepted capability contract; T3 independence premise | concrete bridge/materialization is implementation-owned and awaits independent review; optional RAW_RUNS provenance is consumed without changing canonical revision identity or default historical rule | +| `devostasis.ci-normalization.v1`, `devostasis.ci-rerun-link.v1`, `integrity.bands.v1+ci-unit-004+hist-002+ci-normalization-001` | local closed projection implementing PV-CI-UNIT-004/PV-HIST-002; exact R11–R53 expansion of PV-TEST-002 accepted by [PV-REV-TEST-002](https://docs.google.com/document/d/1zsqtsqW-LldkAqYRcO-a9ztPj8ZTg_duwGDvV2N87Ck/edit) (its remaining changes-required point is T9) | implementation-owned offline input/projection; 43 permanent cases and 84 CI variants implemented in 0.6; independent concrete-surface and exact-fixture fidelity review pending; no claim of provider-signed rerun attestation, core GitLab collection or favorable parent-level history completeness | | `devostasis.ci-outcomes.gitlab.v1` | PV-CI-NORM-001 | local offline native status mapping; no core GitLab collector or favorable history completeness is claimed | | `devostasis.work-adoption.v1`, `devostasis.work-invocation.v1`, `devostasis.work-result.v1`, `devostasis.work-history-archive.v1`, `devostasis.work-history-audit.v1` | implementation-owned E3 operational adoption, issues #62/#65 | local, introduced in 0.5.0; separate from immutable work/core canonical members and accepted Instrument/Vital semantics; caller reports grant no independent acceptance or execution authority | | `devostasis.bundle.v3`, `PV-BUNDLE-ID-003`, `PV-RECEIPT-IDENTITY-003`, `devostasis.receipt-identity.v1`, `devostasis.observations.v2`, `devostasis.manifest.v2`, `devostasis.execution-receipt.v1`, `devostasis.render.v5` | cumulative PV-RECEIPT-IDENTITY-001/002/003 | independently accepted by PV-REV-RECEIPT-IDENTITY-003; adopted in 0.4.0 with RECEIPT-ID-01..15,17..21. Literal devostasis schema tokens are local choices; rejected ID-16 is preserved, not reassigned | diff --git a/docs/spec/conformance-surface.md b/docs/spec/conformance-surface.md index 060880b..5661c6e 100644 --- a/docs/spec/conformance-surface.md +++ b/docs/spec/conformance-surface.md @@ -18,7 +18,10 @@ The closed file envelope is `schema`, `source`, `cases`. Each case has exactly `schemas/conformance-bridge.schema.json`; execution additionally validates operation inputs, unique IDs across both carriers and exact recorded lineage. The bridge and self-contained drivers are in `tests/surface_bridge.py`; -literal inputs and assertions are in `tests/fixtures/conformance-surface.json`. +literal inputs and assertions are in `tests/fixtures/conformance-surface.json` +and `tests/fixtures/ci-revisions.json`. CI revision drivers are in +`tests/ci_surface_bridge.py`; their native input schema is +`schemas/ci-normalization.schema.json`. No arbitrary module, executable, test-name pointer or evaluated code is admitted. Each check contains a JSON path array and exactly one of `equals`, `not_equals`, @@ -35,6 +38,8 @@ the permanent case and checkpoint path, including its subordinate variant. | `independence.v1` | real Actions attempts → one immutable revision → governed Integrity consuming positive `ci.sample_semantics=RAW_RUNS`; history failure remains sticky and retry count cannot establish independent samples | | `multi-vital.v1` | one explicit shared commit/change-request/issue/branch inventory graph → normal derivation → seven-Vital snapshot; dependency provenance and absent aggregate assertions inspect the same execution | | `bundle.v1` | persisted canonical core bundle, canonical byte/digest production, member access failure, receipt binding, public-only verification, private/convenience exclusion, immutable collision | +| `vital-consumers.v1` | actual degraded Vital evaluation, gauge qualification and per-Vital delta over literal observation inputs | +| `ci-revisions.v1` | version-pinned native CI normalization → admitted observations → governed Integrity → real isolated HistoryStore → persisted bundle verification; explicit BASELINE/CONTINUE/GAP operations, literal current/history/coverage assertions | | `history-store.v1` | real isolated FilesystemHistoryStore, idempotent put, failed commit, missing predecessor, permission-domain failure and deterministic two-writer checkpoints/retry | Bundle/store scenarios pin `devostasis.manifest.v2`, `devostasis.bundle.v3`, @@ -43,11 +48,13 @@ registry to verify exact source content. Changing that tuple requires a new scenario version. Provider maps are explicit fixture discriminators; an unknown or mixed provider/map pair is refused. -The GitLab surface is a pure offline outcome normalizer, not the planned core -GitLab collector. It establishes no pipeline-history completeness. R5–R10 here -materialize the native normalization/contribution boundary; independent review -of downstream exact fixture obligations remains separate. The bridge does not -claim all of R11–R53 or INT-TOTAL-14 from those six cases. +The GitLab surface is an offline parent normalizer, separate from the planned +core GitLab collector. It establishes current pipeline identity/state and +retains observed failures, without claiming favorable pipeline-history +completeness. Child-only current verification is explicitly raw/degraded; +canonical parent history remains unknown. Exact Actions/suite overlap requires +check_suite_id, and cross-id reruns require scope-bound linear proof. Neither +names nor app equality deduplicate parents. See [integrity-ci.md](integrity-ci.md). T3 introduces the opt-in rule `integrity.bands.v1+ci-unit-004+hist-002+independence-001`. Absent provenance @@ -64,8 +71,14 @@ overlapping lock holder, commits writer A, refuses stale writer B, inspects latest and all immutable candidates, then rebuilds and commits B. Index access denial remains `HISTORY_STORE_FORBIDDEN`, hence HISTORY_GAP, never BASELINE. -Twenty permanent IDs are executable here: R5–R10, T3, T6, T8, -ART-05/08/09/10/11/15 and RPT-4/5/6/8/9. Nineteen reduce the prior list of 62 -unmaterialized cases; RPT-8 additionally gains an operation fixture. The remaining -43 are R11–R53. B1 stays open, as do independent concrete-surface/exact-fixture -acceptance, T9 cell-key delivery, core GitLab and external B7 evidence. +Sixty-three permanent IDs execute across seven scenario versions: R5–R53, +T3, T6, T8, ART-05/08/09/10/11/15 and RPT-4/5/6/8/9. This materializes the +former 62-case gap and adds an RPT-8 operation fixture. R11–R53 add 43 cases +with 84 subordinate CI variants; each variant executes all lifecycle steps. +Permanent IDs are unique across both bridge files and the legacy vector corpus. +Checks remain active under optimized Python. Expectations are literal research +obligations, not snapshots generated by the implementation under test. + +B1 stays open for independent concrete-surface and whole-case exact-fixture +acceptance and separate T9 cell-key delivery. Core GitLab and external B7 +evidence remain separate. This implementation does not declare those accepted. diff --git a/docs/spec/conformance.md b/docs/spec/conformance.md index f03375d..132f9ab 100644 --- a/docs/spec/conformance.md +++ b/docs/spec/conformance.md @@ -550,8 +550,9 @@ requirements without assigning new research case identifiers. The successor operation bridge is documented in [conformance-surface.md](conformance-surface.md). It executes implementation materializations under the accepted capability contract, with exact-fixture and -concrete-surface independent review still pending. B1 remains open: R11..R53 -still need exact executable materialization; T9 cell-key delivery remains separate. +concrete-surface independent review still pending. R11..R53 now have exact-ID +implementation fixtures below. B1 remains open for independent whole-case +acceptance and separate T9 cell-key delivery. The existing v1 vector corpus and all permanent identifiers retain their meaning. ## Versioned operation bridge (0.6 implementation materialization) @@ -688,3 +689,66 @@ not close B1's missing accepted exact fixtures or B7's sustained calibration. | v1/v2 replay and comparison boundary | `test_v1_and_v2_replay_separately_and_do_not_compare_across_lineage`, `test_v2_frozen_example_replays_and_has_explicit_schemas` | | independent edit oracle | `test_byte_line_algorithm_matches_independent_lcs_oracle` | | exclusion applies before historical blob reads | `test_historic_proof_does_not_read_policy_excluded_blob_bytes` | + +## R11–R53 implementation materialization (0.6) + +The permanent-ID expansion of PV-TEST-002 is accepted by PV-REV-TEST-002; +this local materialization still requires independent fidelity review. Later +PV-CI-UNIT-004/PV-HIST-002 and fail-closed rules govern current/history distinctions. +R17 raw children cannot prove a canonical parent failure or favorable history; +R38 does not grant GitLab attempt-history completeness. Every CI checkpoint +builds, persists and verifies a real bundle. The 43 cases contain 84 CI variants. + +| Case | Executed obligation | Proof | +| --- | --- | --- | +| R11 | degraded possible_bands is a nonauthoritative conservative superset; gauge/delta consume the bound | `bridge:R11` | +| R12 | one failed parent plus five children contributes one failed immutable revision | `bridge:R12` | +| R13 | GitHub suite failure and GitLab failed pipeline compose the same verdict/contribution | `bridge:R13` | +| R14 | canonical parent aggregate governs; children remain provenance without double counting | `bridge:R14` | +| R15 | same parent key collapses, proved rerun chain collapses, unproved distinct ids remain | `bridge:R15` | +| R16 | passing and failing siblings compose one failed revision | `bridge:R16` | +| R17 | child-only fallback declares REVISION_CHILD_AGGREGATE_RAW and degraded coverage; no canonical parent history is invented | `bridge:R17` | +| R18 | latest decisive default-branch revision uses immutable commit time and SHA tie-break, never CI completion time | `bridge:R18` | +| R19 | missing commit time or default-branch association leaves ordering UNKNOWN | `bridge:R19` | +| R20 | enumeration and page boundaries preserve normalized records and governed results | `bridge:R20` | +| R21 | distinct GitLab pipeline ids remain separate parents | `bridge:R21` | +| R22 | same GitLab pipeline id retry has one current parent | `bridge:R22` | +| R23 | distinct GitHub suite ids from one app remain separate parents | `bridge:R23` | +| R24 | only scope-bound linear acyclic rerun proof collapses; cycles/forks/fan-in/unavailable proof preserve parents | `bridge:R24` | +| R25 | parent enumeration and page order are invariant | `bridge:R25` | +| R26 | Actions attempts share one run id and greatest observed attempt governs current state | `bridge:R26` | +| R27 | distinct Actions run ids never merge by descriptive equality | `bridge:R27` | +| R28 | exact workflow check_suite_id deduplicates overlap; unlinked same-app suites remain | `bridge:R28` | +| R29 | passing Actions attempt followed by queued/running retry is currently unresolved | `bridge:R29` | +| R30 | failing Actions attempt followed by queued/running retry is currently unresolved with failure retained | `bridge:R30` | +| R31 | Actions/Checks/linked unresolved surfaces agree; newer disagreement or stale-only evidence fails closed | `bridge:R31` | +| R32 | GitLab same-id retry in progress is unresolved after either prior polarity | `bridge:R32` | +| R33 | greatest Actions attempt in progress governs despite earlier pass/fail attempts | `bridge:R33` | +| R34 | unavailable or contradictory greatest attempt is UNKNOWN, never an older passing attempt | `bridge:R34` | +| R35 | attempt order and page partitions do not change current or history | `bridge:R35` | +| R36 | unresolved plus pass is unresolved, with zero current decisive samples | `bridge:R36` | +| R37 | unresolved plus fail is one current failed revision | `bridge:R37` | +| R38 | GitHub linked and GitLab sibling current verdicts agree without assuming favorable pipeline history completeness | `bridge:R38` | +| R39 | sibling order and page partitions are invariant | `bridge:R39` | +| R40 | unresolved-only evidence is neither PASS nor NO_RECENT_RUNS | `bridge:R40` | +| R41 | unresolved plus canceled and skipped remains unresolved | `bridge:R41` | +| R42 | UNKNOWN dominates failure and unresolved current siblings while historical failure remains | `bridge:R42` | +| R43 | PASS to unresolved to PASS lifecycle keeps one immutable contribution | `bridge:R43` | +| R44 | prior failure survives current pending verification in real durable history | `bridge:R44` | +| R45 | prior pass survives in history while current verification remains unresolved | `bridge:R45` | +| R46 | fail then pass changes current verdict but historical contribution remains failure | `bridge:R46` | +| R47 | PASS to FAIL to PASS keeps absorbing failure in the same revision | `bridge:R47` | +| R48 | Actions/Checks/linked/GitLab prior failure survives pending retry in real stores | `bridge:R48` | +| R49 | historical attempt/sibling order and pagination are invariant | `bridge:R49` | +| R50 | never-decisive unresolved evidence has no fabricated passing historical contribution | `bridge:R50` | +| R51 | 14-day expiry is by immutable commit time, never rerun time | `bridge:R51` | +| R52 | lost retained-failure proof with a broken predecessor becomes UNKNOWN_HISTORY, never favorable history | `bridge:R52` | +| R53 | same-polarity retries keep one immutable contribution and unchanged counts | `bridge:R53` | + +Admission, canonical input, paging and legacy rule boundaries are also checked by +`test_governed_integrity_refuses_forged_native_projections`, +`test_native_input_unknown_shapes_and_ambiguous_bindings_fail_closed`, +`test_page_transport_and_enumeration_do_not_change_canonical_observations`, +`test_partial_inventory_and_partial_parent_cannot_prove_favorable_history`, +`test_normalization_is_opt_in_and_delta_names_the_rule_boundary` and +`test_bridge_wrong_assertion_still_fails_with_optimized_python`. diff --git a/docs/spec/integrity-ci.md b/docs/spec/integrity-ci.md index 417f0c7..e766913 100644 --- a/docs/spec/integrity-ci.md +++ b/docs/spec/integrity-ci.md @@ -32,7 +32,8 @@ is provider-native, never heuristic: - generic GitHub Checks: `check_suite.id`; re-requests keep the same id, so earlier outcomes are not observable (`PARENT_LEVEL_ONLY` provenance). -When Actions runs exist for a revision, check suites created by Actions are +In the historical GitHub collector, when Actions runs exist for a revision, +check suites created by Actions are not collected again (overlap de-duplication). External check apps are only collected when no Actions runs exist at all (see the adapter document). @@ -189,8 +190,10 @@ historical evidence. ## The newest revision The newest in-scope revision speaks for the current state. Its verdict is -read as it was composed: decisive verdicts stand; `VERIFY_UNRESOLVED` makes -the band a conservative superset over the completions the evidence admits; +read as it was composed: decisive verdicts stand; under accepted +PV-INTEGRITY-TOTALITY-001, `VERIFY_UNRESOLVED` permits only an already-true +established historical FAILING predicate to emit DEGRADED/FAILING/EXACT; all +other unresolved regions are UNKNOWN without a band or reachability claim; positively observed `NOT_EXECUTED` and `NON_VERIFY_TERMINAL` fall back to the latest decisive revision and are diagnosed. `UNKNOWN` is none of those: it is the absence of an observation (a `startup_failure`, a conclusion this version @@ -228,3 +231,60 @@ to offline conformance without claiming a core GitLab collector or complete pipeline retry history. The explicit provider/map pair is checked before use. The optional governed RAW_RUNS rule and its exact compatibility boundary are in [vitals.md](vitals.md) and [conformance-surface.md](conformance-surface.md). + +## Versioned native evidence admission (0.6, opt in) + +`devostasis.adapters.ci.observations(evidence, subject)` exposes the offline +`devostasis.ci-normalization.v1` contract. `schemas/ci-normalization.schema.json` +publishes its closed input: project/provider/outcome-map lineage, observation +anchor, configured/inventory coverage, immutable revision metadata, native +parent/child snapshots and explicit rerun links. It admits at most 10,000 rows +per inventory, 1,000 parent pages and 16 MiB canonical input. Native SHA fields +must match revision metadata, parent keys cannot change admitted revisions, +evidence cannot postdate the observation, and unknown fields/lineages fail. +Unknown future outcomes stay UNKNOWN. Contiguous page ordinals are required; +an unfinished last page makes the series PARTIAL. Canonical facts flatten +transport pages, sort and deduplicate identical observations. + +The new path retains distinct provider-native ids: Actions workflow_run.id +with numeric run_attempt; generic check_suite.id; GitLab pipeline.id. For +same-id snapshots, the greatest Actions attempt wins, then observation time; +Checks/GitLab use observation time without inventing numeric attempt history. +Conflicting latest states are UNKNOWN. Only exact Actions check_suite_id +deduplicates a linked suite; Actions governs, suite provenance/failure remains. +A newer disagreeing suite makes current state UNKNOWN. Unlinked same-app +suites and descriptively identical runs remain independent parents. + +`devostasis.ci-rerun-link.v1` requires provider/forge/project/revision scope, +earlier/later native references and AVAILABLE evidence_ref. Only a unique +acyclic linear chain collapses; cycle, fork, fan-in, unavailable or cross-scope +proof preserves all parents and degrades coverage. These links are recorded +acquisition claims, not cryptographic provider attestations. Cross-id histories +retain failures without inventing contiguous attempt completeness. + +Current composition uses UNKNOWN > FAIL > UNRESOLVED > PASS > nonverification +terminal > not executed. Children remain provenance when parents exist. With +only children, latest snapshots per child compose one explicitly degraded +REVISION_CHILD_AGGREGATE_RAW; child failures cannot prove a canonical parent +failed (allowed-failure jobs exist), so history stays UNKNOWN_HISTORY. A +current raw verdict cannot become an AVAILABLE governed result. Partial parent +evidence cannot prove favorable complete history. Known parent failures stay +absorbing across current retries and persisted predecessor unions. + +Default-branch window/order is determined by immutable committed_at, then SHA, +never CI completion/retry time. Off-branch/expired revisions are explicitly +excluded; missing time or branch association leaves ordering UNKNOWN and the +required series PARTIAL. The metadata discloses latest_decisive_default_branch, +verification units, native/retained/aliased parent identities, current attempts, +excluded revisions, diagnostics and coverage. Historical counts remain one +per immutable revision. A broken predecessor cannot erase a proved failure +into favorable unknown history; the real store gap is retained. + +Integrity replays raw native facts and compares all four emitted observation +envelopes, including normalized records and provenance, before evaluation. +Altered project/time/provider/version/digest/records/metadata are refused. This +introduces the optional rule described in [vitals.md](vitals.md); absent these +inputs, the historical collector, evaluator, bundle shapes and bytes replay +unchanged. No core GitLab collection or accepted compatibility migration is +introduced. R11–R53 implementation fixtures exercise this boundary and durable +history; independent whole-case fidelity acceptance remains pending. diff --git a/docs/spec/observations.md b/docs/spec/observations.md index 6c17d0e..0c066aa 100644 --- a/docs/spec/observations.md +++ b/docs/spec/observations.md @@ -164,3 +164,14 @@ The governed Integrity extension consumes its status/freshness/value and adds it to input metadata. It never creates extra canonical revision records. Absent input retains historical evaluation. Unknown/stale/unsupported values fail closed under the versioned rule described in [vitals.md](vitals.md). + +## Optional native CI evidence (0.6) + +`ci.native_evidence` is an AVAILABLE/FRESH record of canonical input under +`devostasis.ci-normalization.v1`; `ci.normalization_provenance` is its derived +AVAILABLE/FRESH record. The helper also emits `ci.configured` and +`ci.revision_verdicts_14d`. Integrity recomputes all four envelopes from native +evidence and checks exact bindings before using them. The project and observation +anchor must agree with the enclosing ObservationSet. See +[integrity-ci.md](integrity-ci.md) and `schemas/ci-normalization.schema.json`. +Absent both new inputs, historical observations retain their existing meaning. diff --git a/docs/spec/vitals.md b/docs/spec/vitals.md index be2a478..3c61072 100644 --- a/docs/spec/vitals.md +++ b/docs/spec/vitals.md @@ -560,3 +560,17 @@ a band. Positive RAW_RUNS cannot be AVAILABLE: it retains failure-sticky one-revision arithmetic, reports DEGRADED when a band is otherwise available, and sample_strength PROVISIONAL_NONINDEPENDENT. Existing UNKNOWN precedence, thresholds/windows and per-Vital comparison boundaries remain intact. + +## Optional Integrity native normalization (0.6) + +`ci.native_evidence` / `ci.normalization_provenance` select +`integrity.bands.v1+ci-unit-004+hist-002+ci-normalization-001`. Both are added to +input metadata; raw facts must reproduce the exact configured, revision-series +and provenance envelopes. Derived ci_normalization and +latest_decisive_default_branch expose the replayed metadata. Incomplete native +coverage reduces an otherwise AVAILABLE evaluation to DEGRADED; required +PARTIAL series/unknown history keep existing UNKNOWN precedence. Thresholds, +14-day window and durable history semantics are unchanged. Absent these inputs, +the exact historical rule remains. Delta treats the new rule as an explicit +per-Vital boundary. If sample_semantics is also present, the combined rule is +`integrity.bands.v1+ci-unit-004+hist-002+ci-normalization-001+independence-001`. diff --git a/pyproject.toml b/pyproject.toml index 956baa1..d48fa79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ dependencies = [] [project.optional-dependencies] -dev = ["pytest>=8"] +dev = ["pytest>=8", "jsonschema>=4.18"] [project.scripts] devostasis = "devostasis.cli:main" diff --git a/schemas/ci-normalization.schema.json b/schemas/ci-normalization.schema.json new file mode 100644 index 0000000..9a89d8f --- /dev/null +++ b/schemas/ci-normalization.schema.json @@ -0,0 +1,778 @@ +{ + "type": "object", + "additionalProperties": false, + "required": [ + "contract", + "project", + "outcome_map", + "observed_at", + "configured", + "coverage", + "revisions", + "parents", + "children", + "rerun_links" + ], + "properties": { + "contract": { + "const": "devostasis.ci-normalization.v1" + }, + "project": { + "$ref": "#/$defs/project" + }, + "outcome_map": { + "enum": [ + "devostasis.ci-outcomes.github.v1", + "devostasis.ci-outcomes.gitlab.v1" + ] + }, + "observed_at": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "configured": { + "type": [ + "boolean", + "null" + ] + }, + "coverage": { + "enum": [ + "COMPLETE", + "PARTIAL", + "STALE", + "UNAVAILABLE" + ] + }, + "revisions": { + "type": "array", + "items": { + "$ref": "#/$defs/revision" + }, + "maxItems": 10000 + }, + "parents": { + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/parent" + }, + "maxItems": 10000 + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "pages" + ], + "properties": { + "pages": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "ordinal", + "has_next", + "items" + ], + "properties": { + "ordinal": { + "type": "integer", + "minimum": 1 + }, + "has_next": { + "type": "boolean" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/parent" + }, + "maxItems": 10000 + } + } + }, + "minItems": 1, + "maxItems": 1000 + } + } + } + ] + }, + "children": { + "type": "array", + "items": { + "$ref": "#/$defs/child" + }, + "maxItems": 10000 + }, + "rerun_links": { + "type": "array", + "items": { + "$ref": "#/$defs/rerun_link" + }, + "maxItems": 10000 + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/drevendev/Devostasis/schemas/ci-normalization.schema.json", + "$defs": { + "project": { + "type": "object", + "additionalProperties": false, + "required": [ + "provider", + "forge_instance", + "immutable_project_id", + "default_branch" + ], + "properties": { + "provider": { + "enum": [ + "github", + "gitlab" + ] + }, + "forge_instance": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "immutable_project_id": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "default_branch": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + } + }, + "reference": { + "type": "object", + "additionalProperties": false, + "required": [ + "surface", + "id" + ], + "properties": { + "surface": { + "enum": [ + "actions", + "checks", + "pipelines" + ] + }, + "id": { + "type": "integer", + "minimum": 1 + } + } + }, + "revision": { + "type": "object", + "additionalProperties": false, + "required": [ + "revision", + "committed_at", + "default_branch" + ], + "properties": { + "revision": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "committed_at": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + }, + "default_branch": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "parent": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "surface", + "revision", + "observed_at", + "coverage", + "native" + ], + "properties": { + "surface": { + "const": "actions" + }, + "revision": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "observed_at": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "coverage": { + "enum": [ + "COMPLETE", + "PARTIAL", + "STALE", + "UNAVAILABLE" + ] + }, + "native": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "head_sha", + "status", + "conclusion", + "run_attempt" + ], + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "head_sha": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "status": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + }, + "conclusion": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + }, + "run_attempt": { + "type": "integer", + "minimum": 1 + }, + "check_suite_id": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "workflow_id": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "name": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + }, + "event": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + }, + "html_url": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + }, + "updated_at": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + }, + "completed_at": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + } + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "surface", + "revision", + "observed_at", + "coverage", + "native" + ], + "properties": { + "surface": { + "const": "checks" + }, + "revision": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "observed_at": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "coverage": { + "enum": [ + "COMPLETE", + "PARTIAL", + "STALE", + "UNAVAILABLE" + ] + }, + "native": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "head_sha", + "status", + "conclusion" + ], + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "head_sha": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "status": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + }, + "conclusion": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + }, + "app": { + "type": "object", + "additionalProperties": false, + "required": [], + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + } + } + }, + "url": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + }, + "updated_at": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + }, + "completed_at": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + } + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "surface", + "revision", + "observed_at", + "coverage", + "native" + ], + "properties": { + "surface": { + "const": "pipelines" + }, + "revision": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "observed_at": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "coverage": { + "enum": [ + "COMPLETE", + "PARTIAL", + "STALE", + "UNAVAILABLE" + ] + }, + "native": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "sha", + "status" + ], + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "sha": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "status": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + }, + "ref": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + }, + "source": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + }, + "web_url": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + }, + "updated_at": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + }, + "finished_at": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + } + } + } + } + } + ] + }, + "child": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "surface", + "revision", + "observed_at", + "coverage", + "native", + "parent" + ], + "properties": { + "surface": { + "const": "check-run" + }, + "revision": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "observed_at": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "coverage": { + "enum": [ + "COMPLETE", + "PARTIAL", + "STALE", + "UNAVAILABLE" + ] + }, + "native": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "head_sha", + "status", + "conclusion" + ], + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "head_sha": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "status": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + }, + "conclusion": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + }, + "name": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + } + } + }, + "parent": { + "anyOf": [ + { + "$ref": "#/$defs/reference" + }, + { + "type": "null" + } + ] + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "surface", + "revision", + "observed_at", + "coverage", + "native", + "parent" + ], + "properties": { + "surface": { + "const": "job" + }, + "revision": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "observed_at": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "coverage": { + "enum": [ + "COMPLETE", + "PARTIAL", + "STALE", + "UNAVAILABLE" + ] + }, + "native": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "sha", + "status" + ], + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "sha": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "status": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + }, + "name": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + } + } + }, + "parent": { + "anyOf": [ + { + "$ref": "#/$defs/reference" + }, + { + "type": "null" + } + ] + } + } + } + ] + }, + "rerun_link": { + "type": "object", + "additionalProperties": false, + "required": [ + "contract", + "provider", + "forge_instance", + "project_id", + "revision", + "earlier", + "later", + "status", + "evidence_ref" + ], + "properties": { + "contract": { + "const": "devostasis.ci-rerun-link.v1" + }, + "provider": { + "enum": [ + "github", + "gitlab" + ] + }, + "forge_instance": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "project_id": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "revision": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "earlier": { + "$ref": "#/$defs/reference" + }, + "later": { + "$ref": "#/$defs/reference" + }, + "status": { + "enum": [ + "AVAILABLE", + "UNKNOWN", + "UNAVAILABLE", + "FORBIDDEN", + "ERROR" + ] + }, + "evidence_ref": { + "type": [ + "string", + "null" + ], + "maxLength": 2048 + } + } + } + }, + "description": "Bounded provider-native offline acquisition facts. Runtime additionally enforces the 16 MiB bound, timestamps, provider/map/surface and immutable revision bindings, unique revision metadata, contiguous page ordinals and explicit rerun proof scope. Canonical output flattens pages, sorts and deduplicates identical facts. Evidence links are acquisition claims, not provider signatures." +} diff --git a/schemas/conformance-bridge.schema.json b/schemas/conformance-bridge.schema.json index 7338562..5d990b2 100644 --- a/schemas/conformance-bridge.schema.json +++ b/schemas/conformance-bridge.schema.json @@ -765,6 +765,351 @@ } } } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "case", + "scenario", + "given", + "checks" + ], + "properties": { + "case": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "scenario": { + "const": "vital-consumers.v1" + }, + "given": { + "type": "object", + "additionalProperties": false, + "required": [ + "observed_at", + "observations" + ], + "properties": { + "observed_at": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "observations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "observation_id", + "value_type", + "status", + "value" + ], + "properties": { + "observation_id": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "value_type": { + "enum": [ + "count", + "ratio", + "duration", + "boolean", + "enum", + "string", + "set", + "series", + "record" + ] + }, + "status": { + "enum": [ + "AVAILABLE", + "PARTIAL", + "UNAVAILABLE", + "FORBIDDEN", + "UNKNOWN", + "ERROR" + ] + }, + "freshness": { + "enum": [ + "FRESH", + "STALE", + "UNKNOWN" + ] + }, + "value": {}, + "coverage": { + "type": [ + "object", + "null" + ] + } + } + }, + "minItems": 1 + } + } + }, + "checks": { + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "equals" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "equals": {} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "not_equals" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "not_equals": {} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "contains" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "contains": {} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "absent" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "absent": { + "const": true + } + } + } + ] + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "case", + "scenario", + "given", + "checks" + ], + "properties": { + "case": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "scenario": { + "const": "ci-revisions.v1" + }, + "given": { + "type": "object", + "additionalProperties": false, + "required": [ + "variants" + ], + "properties": { + "variants": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "steps" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "history", + "evidence" + ], + "properties": { + "history": { + "enum": [ + "BASELINE", + "CONTINUE", + "GAP" + ] + }, + "evidence": { + "$ref": "ci-normalization.schema.json" + } + } + }, + "minItems": 1 + } + } + }, + "minItems": 1 + } + } + }, + "checks": { + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "equals" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "equals": {} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "not_equals" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "not_equals": {} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "contains" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "contains": {} + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "absent" + ], + "properties": { + "path": { + "type": "array", + "items": { + "type": [ + "string", + "integer" + ] + } + }, + "absent": { + "const": true + } + } + } + ] + } + } + } } ] } diff --git a/scripts/check_conformance_surface.py b/scripts/check_conformance_surface.py index ddd5ccc..04e4cfb 100644 --- a/scripts/check_conformance_surface.py +++ b/scripts/check_conformance_surface.py @@ -6,12 +6,12 @@ ROOT = Path(__file__).resolve().parents[1] sys.path[:0] = [str(ROOT / "src"), str(ROOT / "tests")] -from surface_bridge import CONTRACT, execute, load +from surface_bridge import CONTRACT, execute, load_suite from devostasis.vectors import load as load_vectors def main(): - cases = load(ROOT / "tests/fixtures/conformance-surface.json") + cases = load_suite() old = {v.case for v in load_vectors([ROOT / "tests/vectors"])} if old.intersection(c["case"] for c in cases): raise ValueError("duplicate permanent case across carriers") for case in cases: diff --git a/src/devostasis/adapters/ci.py b/src/devostasis/adapters/ci.py new file mode 100644 index 0000000..ac6a841 --- /dev/null +++ b/src/devostasis/adapters/ci.py @@ -0,0 +1,319 @@ +"""Versioned offline CI evidence: native parents, explicit links and child fallback. + +Input is a bounded projection of provider evidence, not a transport or a new +classifier. Old GitHub collection and historical observation bytes are unchanged. +""" + +from copy import deepcopy + +from .. import canonical, timeutil +from ..observations import Observation, ObservationSet +from ..revision_history import current_history +from . import github_ci as gh, gitlab_ci as gl + +CONTRACT = "devostasis.ci-normalization.v1" +LINK_CONTRACT = "devostasis.ci-rerun-link.v1" +NATIVE = "ci.native_evidence" +PROVENANCE = "ci.normalization_provenance" +SURFACES = {"github": {"actions": gh.ACTIONS_KIND, "checks": "github_check_suite"}, + "gitlab": {"pipelines": "gitlab_pipeline"}} +PREFIX = {"actions": "github_actions:workflow_run:", "checks": "github_checks:check_suite:", "pipelines": "gitlab:pipeline:"} +AVAILABILITY = {"COMPLETE", "PARTIAL", "STALE", "UNAVAILABLE"} +NATIVE_KEYS = {"actions": {"id", "head_sha", "run_attempt", "status", "conclusion", "check_suite_id", "name", "event", "workflow_id", "html_url", "updated_at", "completed_at"}, + "checks": {"id", "head_sha", "status", "conclusion", "app", "url", "updated_at", "completed_at"}, + "pipelines": {"id", "sha", "status", "ref", "source", "web_url", "updated_at", "finished_at"}, + "check-run": {"id", "head_sha", "status", "conclusion", "name"}, + "job": {"id", "sha", "status", "name"}} + + +def require(condition, message): + if not condition: raise ValueError(message) + + +def fields(value, required, allowed=None): + require(isinstance(value, dict) and set(required) <= set(value) <= set(allowed or required), "unknown or missing CI evidence fields") + + +def text(value): + require(isinstance(value, str) and 0 < len(value) <= 2048, "CI identity/text unavailable") + return value + + +def identifier(value): + require(type(value) is int and value > 0, "provider id must be a positive integer") + return value + + +def reference(value, provider): + fields(value, ("surface", "id")) + require(value["surface"] in SURFACES[provider], "foreign parent surface") + return PREFIX[value["surface"]] + str(identifier(value["id"])) + + +def evidence(row, provider, *, child=False): + fields(row, ("surface", "revision", "observed_at", "coverage", "native", *(["parent"] if child else []))) + surface, data = row["surface"], row["native"] + require(surface in (("check-run",) if provider == "github" else ("job",)) if child else surface in SURFACES[provider], "foreign provider evidence") + required = {"id", "sha" if provider == "gitlab" else "head_sha", "status"} + if provider == "github": required.add("conclusion") + if surface == "actions": required.add("run_attempt") + fields(data, required, NATIVE_KEYS[surface]); identifier(data["id"]) + text(row["revision"]); timeutil.parse_ts(row["observed_at"]) + require(row["coverage"] in AVAILABILITY, "unknown CI source coverage") + require(data.get("sha" if provider == "gitlab" else "head_sha") == row["revision"], "native revision binding mismatch") + for key in ("status", "conclusion"): + if key in data: require(data[key] is None or isinstance(data[key], str), "untyped native outcome") + if surface == "actions": identifier(data["run_attempt"]) + for key in ("name", "event", "html_url", "url", "ref", "source", "web_url", "updated_at", "completed_at", "finished_at"): + if data.get(key) is not None: text(data[key]) + if data.get("workflow_id") is not None: identifier(data["workflow_id"]) + if data.get("check_suite_id") is not None: identifier(data["check_suite_id"]) + if "app" in data: + fields(data["app"], (), ("id", "slug")) + if "id" in data["app"]: identifier(data["app"]["id"]) + if "slug" in data["app"]: text(data["app"]["slug"]) + if child and row["parent"] is not None: reference(row["parent"], provider) + state = gl.normalize_outcome(data["status"]) if provider == "gitlab" else gh.normalize_outcome(data["status"], data["conclusion"]) + if provider == "github" and data["status"] not in ("completed", "queued", "in_progress", "waiting", "pending", "requested"): + state = "UNKNOWN" + return "UNKNOWN" if row["coverage"] in ("STALE", "UNAVAILABLE") else state + + +def canonical_input(value): + fields(value, ("contract", "project", "outcome_map", "observed_at", "configured", "coverage", "revisions", "parents", "children", "rerun_links")) + require(value["contract"] == CONTRACT, "unsupported CI normalization contract") + project = value["project"] + fields(project, ("provider", "forge_instance", "immutable_project_id", "default_branch")) + require(project["provider"] in SURFACES, "unsupported CI provider") + for item in project.values(): text(item) + expected = gh.OUTCOME_MAP_VERSION if project["provider"] == "github" else gl.OUTCOME_MAP_VERSION + require(value["outcome_map"] == expected, "mixed provider/outcome-map lineage") + timeutil.parse_ts(value["observed_at"]) + require(value["configured"] is None or type(value["configured"]) is bool, "CI configured must be boolean or unknown") + require(value["coverage"] in AVAILABILITY, "unknown CI inventory coverage") + result = deepcopy(value) + if isinstance(result["parents"], dict): + fields(result["parents"], ("pages",)) + pages = result["parents"]["pages"] + require(isinstance(pages, list) and 0 < len(pages) <= 1000, "CI pagination cap") + for page in pages: + fields(page, ("ordinal", "has_next", "items")); identifier(page["ordinal"]) + require(type(page["has_next"]) is bool and isinstance(page["items"], list), "malformed CI page") + pages.sort(key=lambda p: p["ordinal"]) + require([p["ordinal"] for p in pages] == list(range(1, len(pages) + 1)), "missing or duplicate CI page") + require(all(p["has_next"] for p in pages[:-1]), "contradictory CI continuation") + if pages[-1]["has_next"]: result["coverage"] = "PARTIAL" + result["parents"] = [item for p in pages for item in p["items"]] + for key in ("revisions", "parents", "children", "rerun_links"): + rows = result[key] + require(isinstance(rows, list) and len(rows) <= 10000, "CI evidence cardinality cap") + result[key] = [canonical.loads(data.decode()) for data in sorted({canonical.canonical_bytes(row) for row in rows})] + require(len(canonical.canonical_bytes(result)) <= 16 * 1024 * 1024, "CI evidence byte cap") + return result + + +def parent_from_snapshots(rows, provider): + surface = rows[0]["surface"] + if surface == "actions": + suites = {r["native"]["check_suite_id"] for r in rows if r["native"].get("check_suite_id") is not None} + require(len(suites) <= 1, "workflow run changed immutable suite binding") + ordinal = max(r["native"]["run_attempt"] for r in rows) if surface == "actions" else None + latest = [r for r in rows if surface != "actions" or r["native"]["run_attempt"] == ordinal] + instant = max(timeutil.parse_ts(r["observed_at"]) for r in latest) + latest = [r for r in latest if timeutil.parse_ts(r["observed_at"]) == instant] + states = {evidence(r, provider) for r in latest} + current = next(iter(states)) if len(states) == 1 else "UNKNOWN" + row = min(latest, key=canonical.canonical_bytes); data = row["native"] + attempts = [{"attempt": r["native"]["run_attempt"] if surface == "actions" else None, "state": evidence(r, provider)} for r in rows] + attempts = [canonical.loads(b.decode()) for b in sorted({canonical.canonical_bytes(a) for a in attempts})] + if any(r["coverage"] == "PARTIAL" for r in rows): + # A partial parent projection cannot prove that its attempt history is + # complete, even if the visible attempt numbers happen to be contiguous. + attempts.append({"attempt": None, "state": "UNKNOWN"}) + parent = {"parent_id": PREFIX[surface] + str(data["id"]), "kind": SURFACES[provider][surface], + "name": data.get("name") or surface, "event": data.get("event") or data.get("source"), + "current_attempt": ordinal, "current_state": current, "attempts_observed": attempts, + "attempts_complete": False, "history_state": "UNKNOWN_HISTORY", "url": data.get("html_url") or data.get("web_url") or data.get("url")} + state, complete = current_history([parent]); parent.update(history_state=state, attempts_complete=complete) + return parent + + +def collapse_links(parents, links, project, revision, diagnostics): + """Only non-branching, acyclic, scope-bound provider evidence can collapse ids.""" + forward, backward, involved = {}, {}, set() + valid = True + for link in links: + fields(link, ("contract", "provider", "forge_instance", "project_id", "revision", "earlier", "later", "status", "evidence_ref")) + require(link["contract"] == LINK_CONTRACT, "unknown rerun-link lineage") + require(link["provider"] in SURFACES and link["status"] in ("AVAILABLE", "UNKNOWN", "UNAVAILABLE", "FORBIDDEN", "ERROR"), "unknown rerun-link availability/provider") + for key in ("forge_instance", "project_id", "revision"): text(link[key]) + if link["evidence_ref"] is not None: text(link["evidence_ref"]) + a, b = reference(link["earlier"], project["provider"]), reference(link["later"], project["provider"]) + scoped = (link["provider"] == project["provider"] and link["forge_instance"] == project["forge_instance"] + and link["project_id"] == project["immutable_project_id"] and link["revision"] == revision + and a in parents and b in parents and parents[a]["kind"] == parents[b]["kind"]) + if not scoped or link["status"] != "AVAILABLE" or not isinstance(link["evidence_ref"], str) or not link["evidence_ref"]: + valid = False; continue + involved.update((a, b)) + if (a in forward and forward[a] != b) or (b in backward and backward[b] != a) or a == b: valid = False + forward[a] = b; backward[b] = a + roots = sorted(involved - set(backward)) + visited, chains = set(), [] + for root in roots: + chain, cursor = [], root + while cursor not in visited: + visited.add(cursor); chain.append(cursor) + if cursor not in forward: break + cursor = forward[cursor] + else: + valid = False + chains.append(chain) + if visited != involved: valid = False + if not valid: + diagnostics.add("RERUN_LINK_UNPROVEN_OR_AMBIGUOUS") + return parents, {} + output, aliases = dict(parents), {} + for chain in chains: + if len(chain) < 2: continue + # Keep the oldest native key stable; current state comes only from leaf. + parent = {**parents[chain[-1]], "parent_id": chain[0]} + parent["current_attempt"] = None + parent["attempts_observed"] = [{"attempt": None, "state": state} for state in sorted({a["state"] for key in chain for a in parents[key]["attempts_observed"]})] + state, complete = current_history([parent]); parent.update(history_state=state, attempts_complete=complete) + for key in chain: output.pop(key) + output[chain[0]] = parent; aliases[chain[0]] = chain + return output, aliases + + +def normalize(value): + value = canonical_input(value) + project, at = value["project"], timeutil.parse_ts(value["observed_at"]) + provider = project["provider"] + start = timeutil.minus_days(at, 14) + diagnostics, scope, excluded, seen = set(), {}, [], set() + ordering = "KNOWN" + for row in value["revisions"]: + fields(row, ("revision", "committed_at", "default_branch")); ref = text(row["revision"]) + require(ref not in seen, "duplicate immutable revision metadata"); seen.add(ref) + require(row["default_branch"] is None or type(row["default_branch"]) is bool, "untyped default-branch association") + if row["default_branch"] is False: + excluded.append({**row, "reason": "OFF_DEFAULT_BRANCH"}); continue + if row["default_branch"] is None or row["committed_at"] is None: + ordering = "UNKNOWN"; diagnostics.add("REVISION_ORDERING_UNAVAILABLE") + excluded.append({**row, "reason": "ORDERING_UNAVAILABLE"}); continue + committed = timeutil.parse_ts(row["committed_at"]) + if not start <= committed <= at: + excluded.append({**row, "reason": "OUTSIDE_WINDOW"}); continue + scope[ref] = row + grouped, children, bindings = {}, {}, {} + for row in value["parents"]: + evidence(row, provider) + require(timeutil.parse_ts(row["observed_at"]) <= at, "CI evidence is from after the observation boundary") + require(row["revision"] in seen, "parent has no immutable revision metadata") + if row["revision"] in scope: + key = PREFIX[row["surface"]] + str(row["native"]["id"]) + require(key not in bindings or bindings[key] == row["revision"], "native parent identity changed immutable revision") + bindings[key] = row["revision"] + grouped.setdefault(row["revision"], {}).setdefault(key, []).append(row) + for row in value["children"]: + evidence(row, provider, child=True) + require(timeutil.parse_ts(row["observed_at"]) <= at, "child evidence is from after the observation boundary") + require(row["revision"] in seen, "child has no immutable revision metadata") + if row["parent"] is not None: + parent_key = reference(row["parent"], provider) + require(parent_key not in bindings or bindings[parent_key] == row["revision"], "child parent belongs to another revision") + children.setdefault(row["revision"], []).append(row) + links = {} + for row in value["rerun_links"]: + require(isinstance(row, dict) and row.get("revision") in scope, "rerun link has no admitted revision") + links.setdefault(row["revision"], []).append(row) + records, provenance, units = [], {}, {} + for ref, revision in sorted(scope.items(), key=lambda pair: (timeutil.parse_ts(pair[1]["committed_at"]), pair[0])): + rows = grouped.get(ref, {}) + parents = {key: parent_from_snapshots(facts, provider) for key, facts in rows.items()} + retained_suites = [] + if provider == "github": + overlap = {} + for key, facts in rows.items(): + for fact in facts: + suite = fact["native"].get("check_suite_id") if fact["surface"] == "actions" else None + if suite is not None: overlap.setdefault("github_checks:check_suite:" + str(suite), set()).add(key) + for suite, actions in sorted(overlap.items()): + if suite not in parents: continue + if len(actions) != 1: + diagnostics.add("ACTIONS_CHECKS_OVERLAP_AMBIGUOUS"); continue + target = parents[next(iter(actions))]; other = parents.pop(suite) + if any(a["state"] == gh.VERIFY_FAIL for a in other["attempts_observed"]) and not any(a["state"] == gh.VERIFY_FAIL for a in target["attempts_observed"]): + target["attempts_observed"].append({"attempt": None, "state": gh.VERIFY_FAIL}) + # A newer unresolved suite cannot be resurrected as an older pass. + latest_suite = max(timeutil.parse_ts(r["observed_at"]) for r in rows[suite]) + latest_actions = max(timeutil.parse_ts(r["observed_at"]) for r in rows[next(iter(actions))]) + if latest_suite > latest_actions and other["current_state"] != target["current_state"]: + target["current_state"] = "UNKNOWN"; diagnostics.add("LINKED_SURFACES_DISAGREE") + retained_suites.append(suite) + parents, aliases = collapse_links(parents, links.get(ref, []), project, ref, diagnostics) + unit = "PARENT_AGGREGATE" + if not parents and children.get(ref): + # Child snapshots have identity too: earlier terminal states must + # not overrule a newer pending observation of that same child. + by_child = {} + for row in children[ref]: by_child.setdefault(row["native"]["id"], []).append(row) + states = [] + for facts in by_child.values(): + latest = max(timeutil.parse_ts(r["observed_at"]) for r in facts) + current = {evidence(r, provider, child=True) for r in facts if timeutil.parse_ts(r["observed_at"]) == latest} + states.append(next(iter(current)) if len(current) == 1 else "UNKNOWN") + parents = {"revision-child-aggregate:" + ref: {"parent_id": "revision-child-aggregate:" + ref, + "kind": "revision_child_aggregate_raw", "name": "child-only fallback", "event": None, + "current_attempt": None, "current_state": gh.compose_current(states), + # Raw child failures are not proof a canonical parent failed + # (allowed-failure jobs exist). Keep them in native provenance. + "attempts_observed": [{"attempt": None, "state": "UNKNOWN"}], + "attempts_complete": False, "history_state": "UNKNOWN_HISTORY", "url": None}} + unit = "REVISION_CHILD_AGGREGATE_RAW"; diagnostics.add("CHILD_ONLY_NONINDEPENDENT") + ordered = [parents[key] for key in sorted(parents)] + state, complete = current_history(ordered) + current = gh.compose_current([p["current_state"] for p in ordered]) + records.append({"revision": ref, "committed_at": revision["committed_at"], "parents": ordered, + "current_verdict": current, "history_state": state, "historical_contribution": gh.contribution(state), + "history_provenance": gh.ATTEMPT_LEVEL if ordered and all(p["kind"] == gh.ACTIONS_KIND for p in ordered) else gh.PARENT_LEVEL_ONLY if ordered else None, + "history_complete": complete}) + units[ref] = unit + provenance[ref] = {"parent_ids": sorted(parents), "native_parent_ids": sorted(rows), "retained_suites": retained_suites, + "rerun_aliases": aliases, "child_ids": sorted({r["native"]["id"] for r in children.get(ref, [])}), + "current_attempts": {p["parent_id"]: {"ordinal": p["current_attempt"], "terminal": p["current_state"] not in ("UNKNOWN", gh.VERIFY_UNRESOLVED), "state": p["current_state"]} for p in ordered}} + if any(row["coverage"] != "COMPLETE" for row in value["parents"] + value["children"]): diagnostics.add("NATIVE_EVIDENCE_INCOMPLETE") + if any(p["current_state"] == "UNKNOWN" for record in records for p in record["parents"]): diagnostics.add("CURRENT_PARENT_UNKNOWN") + decisive = [r for r in records if r["current_verdict"] in (gh.VERIFY_PASS, gh.VERIFY_FAIL)] + latest = max(decisive, key=lambda r: (timeutil.parse_ts(r["committed_at"]), r["revision"]))["revision"] if decisive else None + meta = {"contract": CONTRACT, "coverage": "DEGRADED" if diagnostics or value["coverage"] != "COMPLETE" else "COMPLETE", + "ordering_status": ordering, "latest_decisive_default_branch": "UNKNOWN" if ordering == "UNKNOWN" else latest, + "verification_units": units, "diagnostics": sorted(diagnostics), "provenance": provenance, "excluded_revisions": excluded} + return {"contract": CONTRACT, "evidence": value, "records": records, "metadata": meta} + + +def observations(value, subject): + normalized = normalize(value); value = normalized["evidence"] + require(all(subject.get(k) == v for k, v in value["project"].items()), "CI project/subject binding mismatch") + obs = ObservationSet(subject=deepcopy(subject), observed_at=value["observed_at"]) + common = {"provider": value["project"]["provider"], "collected_at": obs.observed_at, "adapter_version": CONTRACT, + "source_ref": "native-ci:" + canonical.digest(value)} + obs.add(Observation(observation_id=NATIVE, status="AVAILABLE", value_type="record", value=value, **common)) + obs.add(Observation(observation_id=PROVENANCE, status="AVAILABLE", value_type="record", value=normalized["metadata"], **common)) + configured = value["configured"] + obs.add(Observation(observation_id="ci.configured", status="AVAILABLE" if configured is not None else "UNKNOWN", value_type="boolean", value=configured, **common)) + status = "AVAILABLE" if value["coverage"] == "COMPLETE" and normalized["metadata"]["ordering_status"] == "KNOWN" else "PARTIAL" + obs.add(Observation(observation_id="ci.revision_verdicts_14d", status=status, value_type="series", value=normalized["records"], **common)) + return obs + + +def admitted_metadata(obs): + source, metadata = obs.get(NATIVE), obs.get(PROVENANCE) + require(source is not None and source.good and source.value_type == "record", "native CI evidence unavailable") + expected = observations(source.value, obs.subject) + require(expected.observed_at == obs.observed_at, "native CI observation-time mismatch") + for key in (NATIVE, PROVENANCE, "ci.configured", "ci.revision_verdicts_14d"): + actual, wanted = obs.get(key), expected.get(key) + require(actual is not None and canonical.canonical_bytes(actual.to_dict()) == canonical.canonical_bytes(wanted.to_dict()), + "native CI projection does not replay: " + key) + return metadata.value diff --git a/src/devostasis/vitals/integrity.py b/src/devostasis/vitals/integrity.py index 72ffaaf..da6b6e9 100644 --- a/src/devostasis/vitals/integrity.py +++ b/src/devostasis/vitals/integrity.py @@ -219,11 +219,22 @@ def evaluate(obs: ObservationSet) -> VitalResult: An absent discriminator preserves historical bytes. A present discriminator is consumed, versioned and fail closed; retries never create revision samples. """ + from ..adapters.ci import NATIVE, PROVENANCE, admitted_metadata + native = obs.get(NATIVE) is not None or obs.get(PROVENANCE) is not None + metadata = admitted_metadata(obs) if native else None result = _evaluate_revisions(obs) + if metadata is not None: + result.rule_id += "+ci-normalization-001" + result.inputs += input_meta(obs, [NATIVE, PROVENANCE]) + result.derived["ci_normalization"] = metadata + result.derived["latest_decisive_default_branch"] = metadata["latest_decisive_default_branch"] + if metadata["coverage"] == "DEGRADED" and result.evaluation_status == EVAL_AVAILABLE: + result.evaluation_status = EVAL_DEGRADED + result.diagnostics += metadata["diagnostics"] provenance = obs.get("ci.sample_semantics") if provenance is None: return result - result.rule_id = RULE_ID + "+independence-001" + result.rule_id += "+independence-001" result.inputs += input_meta(obs, ["ci.sample_semantics"]) result.derived["sample_semantics"] = provenance.value if provenance.good else None if not provenance.good or provenance.value_type != "enum" or provenance.value not in ("RAW_RUNS", "INDEPENDENT_REVISIONS"): diff --git a/tests/ci_surface_bridge.py b/tests/ci_surface_bridge.py new file mode 100644 index 0000000..aea9f18 --- /dev/null +++ b/tests/ci_surface_bridge.py @@ -0,0 +1,64 @@ +"""R11–R53 drivers: native projection and real governed/store executions.""" + +from copy import deepcopy + +from devostasis import canonical +from devostasis.adapters import ci +from devostasis.bundle import verify_dir +from devostasis.config import single_project +from devostasis.delta import compare +from devostasis.gauges import gauge_for +from devostasis.history import FilesystemHistoryStore +from devostasis.observations import Observation +from devostasis.runner import build_from_observations +from devostasis.vitals import clutter +from helpers import SUBJECT, finalize, obs_set + + +def consumer(given, root): + ci.fields(given, ("observed_at", "observations")) + obs = obs_set(given["observed_at"]) + for row in given["observations"]: + obs.add(Observation(**row)) + result = clutter.evaluate(obs).to_dict() + previous = {**deepcopy(result), "evaluation_status": "AVAILABLE", "band_semantics": "EXACT", "band": "CLEAN"} + delta = compare({"vitals": [result]}, {"vitals": [previous]}, "COMPARABLE", "synthetic-predecessor") + return {"result": result, "gauge": gauge_for(result), + "delta": next(row for row in delta["vitals"] if row["vital_id"] == "clutter")} + + +def revisions(given, root): + ci.fields(given, ("variants",)) + ci.require(isinstance(given["variants"], list) and given["variants"], "CI variants required") + variants = {} + for variant in given["variants"]: + ci.fields(variant, ("name", "steps")) + name = ci.text(variant["name"]) + ci.require(name not in variants, "duplicate CI variant") + ci.require(isinstance(variant["steps"], list) and variant["steps"], "CI scenario steps required") + store = FilesystemHistoryStore(root / str(len(variants))) + project, checkpoints, previous_path = single_project("acme/widget", config_version="1"), [], None + for step in variant["steps"]: + ci.fields(step, ("history", "evidence")) + ci.require(step["history"] in ("BASELINE", "CONTINUE", "GAP"), "unknown history operation") + ci.require((previous_path is None) == (step["history"] == "BASELINE"), "history baseline must be explicit and first") + if step["history"] == "GAP": + # Retention/provenance loss is an actual broken predecessor, + # never a made-up current snapshot or a fresh BASELINE. + (previous_path / "snapshot.json").unlink() + subject = {**SUBJECT, **step["evidence"]["project"]} + obs = finalize(ci.observations(step["evidence"], subject)) + normalized = ci.normalize(step["evidence"]) + bundle = build_from_observations(project, obs, store) + previous_path = store.commit(bundle) + snapshot = canonical.loads(bundle.members["snapshot.json"].decode()) + result = next(v for v in snapshot["vitals"] if v["vital_id"] == "integrity") + current = {r["revision"]: r for r in normalized["records"]} + history = {r["revision"]: r for r in result["derived"]["revision_history"]["records"]} + checkpoints.append({"normalization": normalized["metadata"], "current": current, "history": history, + "integrity": result, "comparison": bundle.manifest["comparison_status"], "verified": verify_dir(previous_path) == [], + "current_revision_count": len(current), + "current_decisive_count": sum(r["current_verdict"] in ("VERIFY_PASS", "VERIFY_FAIL") for r in current.values()), + "current_failed_count": sum(r["current_verdict"] == "VERIFY_FAIL" for r in current.values())}) + variants[name] = {"steps": checkpoints} + return variants diff --git a/tests/fixtures/ci-revisions.json b/tests/fixtures/ci-revisions.json new file mode 100644 index 0000000..2afbc79 --- /dev/null +++ b/tests/fixtures/ci-revisions.json @@ -0,0 +1,17569 @@ +{ + "schema": "devostasis.conformance-bridge.v1", + "source": "PV-TEST-002 exact R11-R53 records, permanent-ID expansion accepted by PV-REV-TEST-002 (https://docs.google.com/document/d/1zsqtsqW-LldkAqYRcO-a9ztPj8ZTg_duwGDvV2N87Ck/edit); PV-CI-UNIT-004, PV-HIST-002 and later accepted fail-closed rules govern projections. Implementation materialization awaits independent review.", + "cases": [ + { + "case": "R11", + "scenario": "vital-consumers.v1", + "given": { + "observed_at": "2026-09-05T12:00:00Z", + "observations": [ + { + "observation_id": "forge.change_requests.open_count", + "value_type": "count", + "status": "AVAILABLE", + "value": 10 + }, + { + "observation_id": "forge.change_requests.stale_open_count_14d", + "value_type": "count", + "status": "AVAILABLE", + "value": 1 + }, + { + "observation_id": "forge.issues.open_count", + "value_type": "count", + "status": "UNAVAILABLE", + "value": null + }, + { + "observation_id": "forge.issues.stale_open_count_30d", + "value_type": "count", + "status": "UNAVAILABLE", + "value": null + }, + { + "observation_id": "git.nondefault_branches.stale_count_30d", + "value_type": "count", + "status": "AVAILABLE", + "value": 0 + } + ] + }, + "checks": [ + { + "path": [ + "result", + "evaluation_status" + ], + "equals": "DEGRADED" + }, + { + "path": [ + "result", + "band" + ], + "equals": "LIGHT" + }, + { + "path": [ + "result", + "band_semantics" + ], + "equals": "CONSERVATIVE_LOWER_BOUND" + }, + { + "path": [ + "result", + "derived", + "possible_bands_semantics" + ], + "equals": "NON_AUTHORITATIVE_CONSERVATIVE_SUPERSET" + }, + { + "path": [ + "result", + "possible_bands" + ], + "contains": "LIGHT" + }, + { + "path": [ + "result", + "possible_bands" + ], + "contains": "CLUTTERED" + }, + { + "path": [ + "result", + "possible_bands" + ], + "contains": "HEAVY" + }, + { + "path": [ + "gauge", + "qualifier" + ], + "equals": "at_least" + }, + { + "path": [ + "delta", + "transition_class" + ], + "equals": "CHANGED" + }, + { + "path": [ + "result", + "exact_reachable_bands" + ], + "absent": true + } + ] + }, + { + "case": "R12", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "parent-and-children", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "completed", + "conclusion": "failure", + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ], + "children": [ + { + "surface": "check-run", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "parent": { + "surface": "checks", + "id": 501 + }, + "native": { + "id": 1, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "name": "synthetic child" + } + }, + { + "surface": "check-run", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "parent": { + "surface": "checks", + "id": 501 + }, + "native": { + "id": 2, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "name": "synthetic child" + } + }, + { + "surface": "check-run", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "parent": { + "surface": "checks", + "id": 501 + }, + "native": { + "id": 3, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "name": "synthetic child" + } + }, + { + "surface": "check-run", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "parent": { + "surface": "checks", + "id": 501 + }, + "native": { + "id": 4, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "name": "synthetic child" + } + }, + { + "surface": "check-run", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "parent": { + "surface": "checks", + "id": 501 + }, + "native": { + "id": 5, + "head_sha": "r", + "status": "completed", + "conclusion": "failure", + "name": "synthetic child" + } + } + ], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "parent-and-children", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "parent-and-children", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "parent-and-children", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "parent-and-children", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "parent-and-children", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "parent-and-children", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "parent-and-children", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "parent-and-children", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "parent-and-children", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "github_checks:check_suite:501" + ] + }, + { + "path": [ + "parent-and-children", + "steps", + 0, + "normalization", + "provenance", + "r", + "child_ids" + ], + "equals": [ + 1, + 2, + 3, + 4, + 5 + ] + }, + { + "path": [ + "parent-and-children", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R13", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "github", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "completed", + "conclusion": "failure", + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "gitlab", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "gitlab", + "forge_instance": "gitlab.example", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.gitlab.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "pipelines", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 101, + "sha": "r", + "status": "failed", + "ref": "main", + "source": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "github", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "github", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "github", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "github", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "github", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "github", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "github", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "github", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "gitlab", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "gitlab", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "gitlab", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "gitlab", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "gitlab", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "gitlab", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "gitlab", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "gitlab", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "github", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "gitlab", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R14", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "aggregate-preferred", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [ + { + "surface": "check-run", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "parent": { + "surface": "actions", + "id": 700 + }, + "native": { + "id": 1, + "head_sha": "r", + "status": "completed", + "conclusion": "failure", + "name": "synthetic child" + } + } + ], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "aggregate-preferred", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "aggregate-preferred", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "aggregate-preferred", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "PASS_ONLY_OBSERVED" + }, + { + "path": [ + "aggregate-preferred", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "aggregate-preferred", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "aggregate-preferred", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "aggregate-preferred", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "aggregate-preferred", + "steps", + 0, + "integrity", + "band" + ], + "equals": "SPARSE" + }, + { + "path": [ + "aggregate-preferred", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "github_actions:workflow_run:700" + ] + }, + { + "path": [ + "aggregate-preferred", + "steps", + 0, + "normalization", + "provenance", + "r", + "child_ids" + ], + "equals": [ + 1 + ] + }, + { + "path": [ + "aggregate-preferred", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R15", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "same-native-key", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "gitlab", + "forge_instance": "gitlab.example", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.gitlab.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "pipelines", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 101, + "sha": "r", + "status": "failed", + "ref": "main", + "source": "push" + } + }, + { + "surface": "pipelines", + "revision": "r", + "observed_at": "2026-09-05T11:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 101, + "sha": "r", + "status": "success", + "ref": "main", + "source": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "explicit-link", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "completed", + "conclusion": "failure", + "app": { + "id": 42, + "slug": "same-app" + } + } + }, + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 502, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ], + "children": [], + "rerun_links": [ + { + "contract": "devostasis.ci-rerun-link.v1", + "provider": "github", + "forge_instance": "github.com", + "project_id": "123456", + "revision": "r", + "earlier": { + "surface": "checks", + "id": 501 + }, + "later": { + "surface": "checks", + "id": 502 + }, + "status": "AVAILABLE", + "evidence_ref": "provider-native-rerun-relation:501:502" + } + ] + } + } + ] + }, + { + "name": "distinct-unproved", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "gitlab", + "forge_instance": "gitlab.example", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.gitlab.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "pipelines", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 101, + "sha": "r", + "status": "failed", + "ref": "main", + "source": "push" + } + }, + { + "surface": "pipelines", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 102, + "sha": "r", + "status": "success", + "ref": "main", + "source": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "same-native-key", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "same-native-key", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "same-native-key", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "same-native-key", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "same-native-key", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "same-native-key", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "same-native-key", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "gitlab:pipeline:101" + ] + }, + { + "path": [ + "explicit-link", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "explicit-link", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "explicit-link", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "explicit-link", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "explicit-link", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "explicit-link", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "explicit-link", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "github_checks:check_suite:501" + ] + }, + { + "path": [ + "distinct-unproved", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "distinct-unproved", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "distinct-unproved", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "distinct-unproved", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "distinct-unproved", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "distinct-unproved", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "distinct-unproved", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "gitlab:pipeline:101", + "gitlab:pipeline:102" + ] + }, + { + "path": [ + "same-native-key", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "explicit-link", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "distinct-unproved", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R16", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "siblings", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 701, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "siblings", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "siblings", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "siblings", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "siblings", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "siblings", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "siblings", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "siblings", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "siblings", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "siblings", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "github_actions:workflow_run:700", + "github_actions:workflow_run:701" + ] + }, + { + "path": [ + "siblings", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R17", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "child-only", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [], + "children": [ + { + "surface": "check-run", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "parent": { + "surface": "checks", + "id": 501 + }, + "native": { + "id": 1, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "name": "synthetic child" + } + }, + { + "surface": "check-run", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "parent": { + "surface": "checks", + "id": 501 + }, + "native": { + "id": 2, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "name": "synthetic child" + } + }, + { + "surface": "check-run", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "parent": { + "surface": "checks", + "id": 501 + }, + "native": { + "id": 3, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "name": "synthetic child" + } + }, + { + "surface": "check-run", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "parent": { + "surface": "checks", + "id": 501 + }, + "native": { + "id": 4, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "name": "synthetic child" + } + }, + { + "surface": "check-run", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "parent": { + "surface": "checks", + "id": 501 + }, + "native": { + "id": 5, + "head_sha": "r", + "status": "completed", + "conclusion": "failure", + "name": "synthetic child" + } + } + ], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "child-only", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "child-only", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "child-only", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "UNKNOWN_HISTORY" + }, + { + "path": [ + "child-only", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": null + }, + { + "path": [ + "child-only", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "child-only", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "child-only", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "child-only", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "child-only", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "revision-child-aggregate:r" + ] + }, + { + "path": [ + "child-only", + "steps", + 0, + "normalization", + "coverage" + ], + "equals": "DEGRADED" + }, + { + "path": [ + "child-only", + "steps", + 0, + "normalization", + "verification_units", + "r" + ], + "equals": "REVISION_CHILD_AGGREGATE_RAW" + }, + { + "path": [ + "child-only", + "steps", + 0, + "current_decisive_count" + ], + "equals": 1 + }, + { + "path": [ + "child-only", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R18", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "revision-time", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "old", + "committed_at": "2026-09-01T00:00:00Z", + "default_branch": true + }, + { + "revision": "new", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "old", + "observed_at": "2026-09-05T11:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "old", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push", + "completed_at": "2026-09-05T11:00:00Z" + } + }, + { + "surface": "actions", + "revision": "new", + "observed_at": "2026-09-04T11:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 800, + "head_sha": "new", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push", + "completed_at": "2026-09-04T11:00:00Z" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "sha-tie-break", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "aa", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + }, + { + "revision": "zz", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "aa", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "aa", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "zz", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 800, + "head_sha": "zz", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "revision-time", + "steps", + 0, + "integrity", + "derived", + "latest_decisive_default_branch" + ], + "equals": "new" + }, + { + "path": [ + "revision-time", + "steps", + 0, + "integrity", + "derived", + "current_revision", + "revision" + ], + "equals": "new" + }, + { + "path": [ + "sha-tie-break", + "steps", + 0, + "integrity", + "derived", + "latest_decisive_default_branch" + ], + "equals": "zz" + }, + { + "path": [ + "sha-tie-break", + "steps", + 0, + "integrity", + "derived", + "current_revision", + "revision" + ], + "equals": "zz" + }, + { + "path": [ + "revision-time", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "sha-tie-break", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R19", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "timestamp-unavailable", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": null, + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "branch-unavailable", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": null + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "timestamp-unavailable", + "steps", + 0, + "normalization", + "ordering_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "timestamp-unavailable", + "steps", + 0, + "integrity", + "derived", + "latest_decisive_default_branch" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "timestamp-unavailable", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "timestamp-unavailable", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "timestamp-unavailable", + "steps", + 0, + "current_revision_count" + ], + "equals": 0 + }, + { + "path": [ + "branch-unavailable", + "steps", + 0, + "normalization", + "ordering_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "branch-unavailable", + "steps", + 0, + "integrity", + "derived", + "latest_decisive_default_branch" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "branch-unavailable", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "branch-unavailable", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "branch-unavailable", + "steps", + 0, + "current_revision_count" + ], + "equals": 0 + }, + { + "path": [ + "timestamp-unavailable", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "branch-unavailable", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R20", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "forward", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "old", + "committed_at": "2026-09-01T00:00:00Z", + "default_branch": true + }, + { + "revision": "new", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "old", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "old", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "new", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 701, + "head_sha": "new", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "reverse", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "new", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + }, + { + "revision": "old", + "committed_at": "2026-09-01T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "new", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 701, + "head_sha": "new", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "old", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "old", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "pages", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "old", + "committed_at": "2026-09-01T00:00:00Z", + "default_branch": true + }, + { + "revision": "new", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": { + "pages": [ + { + "ordinal": 1, + "has_next": true, + "items": [ + { + "surface": "actions", + "revision": "old", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "old", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ] + }, + { + "ordinal": 2, + "has_next": false, + "items": [ + { + "surface": "actions", + "revision": "new", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 701, + "head_sha": "new", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ] + } + ] + }, + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "forward", + "steps", + 0, + "current_revision_count" + ], + "equals": 2 + }, + { + "path": [ + "forward", + "steps", + 0, + "current", + "new", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "forward", + "steps", + 0, + "history", + "new", + "history_state" + ], + "equals": "PASS_ONLY_OBSERVED" + }, + { + "path": [ + "forward", + "steps", + 0, + "history", + "new", + "historical_contribution" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "forward", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 2 + }, + { + "path": [ + "forward", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "forward", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "forward", + "steps", + 0, + "integrity", + "band" + ], + "equals": "SPARSE_MIXED" + }, + { + "path": [ + "reverse", + "steps", + 0, + "current_revision_count" + ], + "equals": 2 + }, + { + "path": [ + "reverse", + "steps", + 0, + "current", + "new", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "reverse", + "steps", + 0, + "history", + "new", + "history_state" + ], + "equals": "PASS_ONLY_OBSERVED" + }, + { + "path": [ + "reverse", + "steps", + 0, + "history", + "new", + "historical_contribution" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "reverse", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 2 + }, + { + "path": [ + "reverse", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "reverse", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "reverse", + "steps", + 0, + "integrity", + "band" + ], + "equals": "SPARSE_MIXED" + }, + { + "path": [ + "pages", + "steps", + 0, + "current_revision_count" + ], + "equals": 2 + }, + { + "path": [ + "pages", + "steps", + 0, + "current", + "new", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "pages", + "steps", + 0, + "history", + "new", + "history_state" + ], + "equals": "PASS_ONLY_OBSERVED" + }, + { + "path": [ + "pages", + "steps", + 0, + "history", + "new", + "historical_contribution" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "pages", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 2 + }, + { + "path": [ + "pages", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "pages", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "pages", + "steps", + 0, + "integrity", + "band" + ], + "equals": "SPARSE_MIXED" + }, + { + "path": [ + "forward", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "reverse", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "pages", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R21", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "distinct-pipelines", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "gitlab", + "forge_instance": "gitlab.example", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.gitlab.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "pipelines", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 101, + "sha": "r", + "status": "failed", + "ref": "main", + "source": "push" + } + }, + { + "surface": "pipelines", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 102, + "sha": "r", + "status": "success", + "ref": "main", + "source": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "distinct-pipelines", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "distinct-pipelines", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "distinct-pipelines", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "distinct-pipelines", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "distinct-pipelines", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "distinct-pipelines", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "distinct-pipelines", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "distinct-pipelines", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "distinct-pipelines", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "gitlab:pipeline:101", + "gitlab:pipeline:102" + ] + }, + { + "path": [ + "distinct-pipelines", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R22", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "in-place", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "gitlab", + "forge_instance": "gitlab.example", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.gitlab.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "pipelines", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 101, + "sha": "r", + "status": "failed", + "ref": "main", + "source": "push" + } + }, + { + "surface": "pipelines", + "revision": "r", + "observed_at": "2026-09-05T11:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 101, + "sha": "r", + "status": "success", + "ref": "main", + "source": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "in-place", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "in-place", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "in-place", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "in-place", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "in-place", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "in-place", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "in-place", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "in-place", + "steps", + 0, + "integrity", + "band" + ], + "equals": "SPARSE_MIXED" + }, + { + "path": [ + "in-place", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "gitlab:pipeline:101" + ] + }, + { + "path": [ + "in-place", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R23", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "distinct-suites", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "completed", + "conclusion": "failure", + "app": { + "id": 42, + "slug": "same-app" + } + } + }, + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 502, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "distinct-suites", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "distinct-suites", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "distinct-suites", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "distinct-suites", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "distinct-suites", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "distinct-suites", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "distinct-suites", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "distinct-suites", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "distinct-suites", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "github_checks:check_suite:501", + "github_checks:check_suite:502" + ] + }, + { + "path": [ + "distinct-suites", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R24", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "chain", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "completed", + "conclusion": "failure", + "app": { + "id": 42, + "slug": "same-app" + } + } + }, + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 502, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "app": { + "id": 42, + "slug": "same-app" + } + } + }, + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 503, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ], + "children": [], + "rerun_links": [ + { + "contract": "devostasis.ci-rerun-link.v1", + "provider": "github", + "forge_instance": "github.com", + "project_id": "123456", + "revision": "r", + "earlier": { + "surface": "checks", + "id": 501 + }, + "later": { + "surface": "checks", + "id": 502 + }, + "status": "AVAILABLE", + "evidence_ref": "provider-native-rerun-relation:501:502" + }, + { + "contract": "devostasis.ci-rerun-link.v1", + "provider": "github", + "forge_instance": "github.com", + "project_id": "123456", + "revision": "r", + "earlier": { + "surface": "checks", + "id": 502 + }, + "later": { + "surface": "checks", + "id": 503 + }, + "status": "AVAILABLE", + "evidence_ref": "provider-native-rerun-relation:502:503" + } + ] + } + } + ] + }, + { + "name": "fork", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "completed", + "conclusion": "failure", + "app": { + "id": 42, + "slug": "same-app" + } + } + }, + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 502, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "app": { + "id": 42, + "slug": "same-app" + } + } + }, + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 503, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ], + "children": [], + "rerun_links": [ + { + "contract": "devostasis.ci-rerun-link.v1", + "provider": "github", + "forge_instance": "github.com", + "project_id": "123456", + "revision": "r", + "earlier": { + "surface": "checks", + "id": 501 + }, + "later": { + "surface": "checks", + "id": 502 + }, + "status": "AVAILABLE", + "evidence_ref": "provider-native-rerun-relation:501:502" + }, + { + "contract": "devostasis.ci-rerun-link.v1", + "provider": "github", + "forge_instance": "github.com", + "project_id": "123456", + "revision": "r", + "earlier": { + "surface": "checks", + "id": 501 + }, + "later": { + "surface": "checks", + "id": 503 + }, + "status": "AVAILABLE", + "evidence_ref": "provider-native-rerun-relation:501:503" + } + ] + } + } + ] + }, + { + "name": "cycle", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "completed", + "conclusion": "failure", + "app": { + "id": 42, + "slug": "same-app" + } + } + }, + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 502, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ], + "children": [], + "rerun_links": [ + { + "contract": "devostasis.ci-rerun-link.v1", + "provider": "github", + "forge_instance": "github.com", + "project_id": "123456", + "revision": "r", + "earlier": { + "surface": "checks", + "id": 501 + }, + "later": { + "surface": "checks", + "id": 502 + }, + "status": "AVAILABLE", + "evidence_ref": "provider-native-rerun-relation:501:502" + }, + { + "contract": "devostasis.ci-rerun-link.v1", + "provider": "github", + "forge_instance": "github.com", + "project_id": "123456", + "revision": "r", + "earlier": { + "surface": "checks", + "id": 502 + }, + "later": { + "surface": "checks", + "id": 501 + }, + "status": "AVAILABLE", + "evidence_ref": "provider-native-rerun-relation:502:501" + } + ] + } + } + ] + }, + { + "name": "fanin", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "completed", + "conclusion": "failure", + "app": { + "id": 42, + "slug": "same-app" + } + } + }, + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 502, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "app": { + "id": 42, + "slug": "same-app" + } + } + }, + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 503, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ], + "children": [], + "rerun_links": [ + { + "contract": "devostasis.ci-rerun-link.v1", + "provider": "github", + "forge_instance": "github.com", + "project_id": "123456", + "revision": "r", + "earlier": { + "surface": "checks", + "id": 501 + }, + "later": { + "surface": "checks", + "id": 503 + }, + "status": "AVAILABLE", + "evidence_ref": "provider-native-rerun-relation:501:503" + }, + { + "contract": "devostasis.ci-rerun-link.v1", + "provider": "github", + "forge_instance": "github.com", + "project_id": "123456", + "revision": "r", + "earlier": { + "surface": "checks", + "id": 502 + }, + "later": { + "surface": "checks", + "id": 503 + }, + "status": "AVAILABLE", + "evidence_ref": "provider-native-rerun-relation:502:503" + } + ] + } + } + ] + }, + { + "name": "unavailable-proof", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "completed", + "conclusion": "failure", + "app": { + "id": 42, + "slug": "same-app" + } + } + }, + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 502, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ], + "children": [], + "rerun_links": [ + { + "contract": "devostasis.ci-rerun-link.v1", + "provider": "github", + "forge_instance": "github.com", + "project_id": "123456", + "revision": "r", + "earlier": { + "surface": "checks", + "id": 501 + }, + "later": { + "surface": "checks", + "id": 502 + }, + "status": "UNKNOWN", + "evidence_ref": "provider-native-rerun-relation:501:502" + } + ] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "chain", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "chain", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "chain", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "chain", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "chain", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "chain", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "chain", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "chain", + "steps", + 0, + "integrity", + "band" + ], + "equals": "SPARSE_MIXED" + }, + { + "path": [ + "chain", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "github_checks:check_suite:501" + ] + }, + { + "path": [ + "fork", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "fork", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "fork", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "fork", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "fork", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "fork", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "fork", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "DEGRADED" + }, + { + "path": [ + "fork", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "fork", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "github_checks:check_suite:501", + "github_checks:check_suite:502", + "github_checks:check_suite:503" + ] + }, + { + "path": [ + "fork", + "steps", + 0, + "normalization", + "coverage" + ], + "equals": "DEGRADED" + }, + { + "path": [ + "fork", + "steps", + 0, + "normalization", + "diagnostics" + ], + "contains": "RERUN_LINK_UNPROVEN_OR_AMBIGUOUS" + }, + { + "path": [ + "cycle", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "cycle", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "cycle", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "cycle", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "cycle", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "cycle", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "cycle", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "DEGRADED" + }, + { + "path": [ + "cycle", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "cycle", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "github_checks:check_suite:501", + "github_checks:check_suite:502" + ] + }, + { + "path": [ + "cycle", + "steps", + 0, + "normalization", + "coverage" + ], + "equals": "DEGRADED" + }, + { + "path": [ + "cycle", + "steps", + 0, + "normalization", + "diagnostics" + ], + "contains": "RERUN_LINK_UNPROVEN_OR_AMBIGUOUS" + }, + { + "path": [ + "fanin", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "fanin", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "fanin", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "fanin", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "fanin", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "fanin", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "fanin", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "DEGRADED" + }, + { + "path": [ + "fanin", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "fanin", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "github_checks:check_suite:501", + "github_checks:check_suite:502", + "github_checks:check_suite:503" + ] + }, + { + "path": [ + "fanin", + "steps", + 0, + "normalization", + "coverage" + ], + "equals": "DEGRADED" + }, + { + "path": [ + "fanin", + "steps", + 0, + "normalization", + "diagnostics" + ], + "contains": "RERUN_LINK_UNPROVEN_OR_AMBIGUOUS" + }, + { + "path": [ + "unavailable-proof", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "unavailable-proof", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "unavailable-proof", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "unavailable-proof", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "unavailable-proof", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "unavailable-proof", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "unavailable-proof", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "DEGRADED" + }, + { + "path": [ + "unavailable-proof", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "unavailable-proof", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "github_checks:check_suite:501", + "github_checks:check_suite:502" + ] + }, + { + "path": [ + "unavailable-proof", + "steps", + 0, + "normalization", + "coverage" + ], + "equals": "DEGRADED" + }, + { + "path": [ + "unavailable-proof", + "steps", + 0, + "normalization", + "diagnostics" + ], + "contains": "RERUN_LINK_UNPROVEN_OR_AMBIGUOUS" + }, + { + "path": [ + "chain", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "fork", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "cycle", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "fanin", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "unavailable-proof", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R25", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "github-forward", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "completed", + "conclusion": "failure", + "app": { + "id": 42, + "slug": "same-app" + } + } + }, + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 502, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "github-reverse", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 502, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "app": { + "id": 42, + "slug": "same-app" + } + } + }, + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "completed", + "conclusion": "failure", + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "github-pages", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": { + "pages": [ + { + "ordinal": 1, + "has_next": true, + "items": [ + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "completed", + "conclusion": "failure", + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ] + }, + { + "ordinal": 2, + "has_next": false, + "items": [ + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 502, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ] + } + ] + }, + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "gitlab-forward", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "gitlab", + "forge_instance": "gitlab.example", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.gitlab.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "pipelines", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 101, + "sha": "r", + "status": "failed", + "ref": "main", + "source": "push" + } + }, + { + "surface": "pipelines", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 102, + "sha": "r", + "status": "success", + "ref": "main", + "source": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "gitlab-reverse", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "gitlab", + "forge_instance": "gitlab.example", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.gitlab.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "pipelines", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 102, + "sha": "r", + "status": "success", + "ref": "main", + "source": "push" + } + }, + { + "surface": "pipelines", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 101, + "sha": "r", + "status": "failed", + "ref": "main", + "source": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "gitlab-pages", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "gitlab", + "forge_instance": "gitlab.example", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.gitlab.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": { + "pages": [ + { + "ordinal": 1, + "has_next": true, + "items": [ + { + "surface": "pipelines", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 101, + "sha": "r", + "status": "failed", + "ref": "main", + "source": "push" + } + } + ] + }, + { + "ordinal": 2, + "has_next": false, + "items": [ + { + "surface": "pipelines", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 102, + "sha": "r", + "status": "success", + "ref": "main", + "source": "push" + } + } + ] + } + ] + }, + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "github-forward", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "github-forward", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "github-forward", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "github-forward", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "github-forward", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "github-forward", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "github-forward", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "github-forward", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "github-forward", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "github_checks:check_suite:501", + "github_checks:check_suite:502" + ] + }, + { + "path": [ + "github-reverse", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "github-reverse", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "github-reverse", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "github-reverse", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "github-reverse", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "github-reverse", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "github-reverse", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "github-reverse", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "github-reverse", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "github_checks:check_suite:501", + "github_checks:check_suite:502" + ] + }, + { + "path": [ + "github-pages", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "github-pages", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "github-pages", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "github-pages", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "github-pages", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "github-pages", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "github-pages", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "github-pages", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "github-pages", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "github_checks:check_suite:501", + "github_checks:check_suite:502" + ] + }, + { + "path": [ + "gitlab-forward", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "gitlab-forward", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "gitlab-forward", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "gitlab-forward", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "gitlab-forward", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "gitlab-forward", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "gitlab-forward", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "gitlab-forward", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "gitlab-forward", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "gitlab:pipeline:101", + "gitlab:pipeline:102" + ] + }, + { + "path": [ + "gitlab-reverse", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "gitlab-reverse", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "gitlab-reverse", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "gitlab-reverse", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "gitlab-reverse", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "gitlab-reverse", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "gitlab-reverse", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "gitlab-reverse", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "gitlab-reverse", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "gitlab:pipeline:101", + "gitlab:pipeline:102" + ] + }, + { + "path": [ + "gitlab-pages", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "gitlab-pages", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "gitlab-pages", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "gitlab-pages", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "gitlab-pages", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "gitlab-pages", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "gitlab-pages", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "gitlab-pages", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "gitlab-pages", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "gitlab:pipeline:101", + "gitlab:pipeline:102" + ] + }, + { + "path": [ + "github-forward", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "github-reverse", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "github-pages", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "gitlab-forward", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "gitlab-reverse", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "gitlab-pages", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R26", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "attempts", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T11:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "attempts", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "attempts", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "attempts", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "attempts", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "attempts", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "attempts", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "attempts", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "attempts", + "steps", + 0, + "integrity", + "band" + ], + "equals": "SPARSE_MIXED" + }, + { + "path": [ + "attempts", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "github_actions:workflow_run:700" + ] + }, + { + "path": [ + "attempts", + "steps", + 0, + "normalization", + "provenance", + "r", + "current_attempts", + "github_actions:workflow_run:700" + ], + "equals": { + "ordinal": 2, + "terminal": true, + "state": "VERIFY_PASS" + } + }, + { + "path": [ + "attempts", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R27", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "distinct-runs", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 701, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "distinct-runs", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "distinct-runs", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "distinct-runs", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "distinct-runs", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "distinct-runs", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "distinct-runs", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "distinct-runs", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "distinct-runs", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "distinct-runs", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "github_actions:workflow_run:700", + "github_actions:workflow_run:701" + ] + }, + { + "path": [ + "distinct-runs", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R28", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "actions-first", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push", + "check_suite_id": 501 + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T11:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push", + "check_suite_id": 501 + } + }, + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T11:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "suite-first", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T11:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "app": { + "id": 42, + "slug": "same-app" + } + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T11:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push", + "check_suite_id": 501 + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push", + "check_suite_id": 501 + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "unlinked-same-app", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push", + "check_suite_id": 501 + } + }, + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 502, + "head_sha": "r", + "status": "completed", + "conclusion": "failure", + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "actions-first", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "actions-first", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "actions-first", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "actions-first", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "actions-first", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "actions-first", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "actions-first", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "actions-first", + "steps", + 0, + "integrity", + "band" + ], + "equals": "SPARSE_MIXED" + }, + { + "path": [ + "actions-first", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "github_actions:workflow_run:700" + ] + }, + { + "path": [ + "actions-first", + "steps", + 0, + "normalization", + "provenance", + "r", + "retained_suites" + ], + "equals": [ + "github_checks:check_suite:501" + ] + }, + { + "path": [ + "suite-first", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "suite-first", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "suite-first", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "suite-first", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "suite-first", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "suite-first", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "suite-first", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "suite-first", + "steps", + 0, + "integrity", + "band" + ], + "equals": "SPARSE_MIXED" + }, + { + "path": [ + "suite-first", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "github_actions:workflow_run:700" + ] + }, + { + "path": [ + "suite-first", + "steps", + 0, + "normalization", + "provenance", + "r", + "retained_suites" + ], + "equals": [ + "github_checks:check_suite:501" + ] + }, + { + "path": [ + "unlinked-same-app", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "unlinked-same-app", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "unlinked-same-app", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "unlinked-same-app", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "unlinked-same-app", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "unlinked-same-app", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "unlinked-same-app", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "github_actions:workflow_run:700", + "github_checks:check_suite:502" + ] + }, + { + "path": [ + "actions-first", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "suite-first", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "unlinked-same-app", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R29", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "queued", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T11:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "queued", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "in_progress", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T11:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "queued", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "queued", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "queued", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "PASS_ONLY_OBSERVED" + }, + { + "path": [ + "queued", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "queued", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "queued", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "queued", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "queued", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "queued", + "steps", + 0, + "normalization", + "provenance", + "r", + "current_attempts", + "github_actions:workflow_run:700" + ], + "equals": { + "ordinal": 2, + "terminal": false, + "state": "VERIFY_UNRESOLVED" + } + }, + { + "path": [ + "queued", + "steps", + 0, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "queued", + "steps", + 0, + "current_failed_count" + ], + "equals": 0 + }, + { + "path": [ + "in_progress", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "in_progress", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "in_progress", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "PASS_ONLY_OBSERVED" + }, + { + "path": [ + "in_progress", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "in_progress", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "in_progress", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "in_progress", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "in_progress", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "in_progress", + "steps", + 0, + "normalization", + "provenance", + "r", + "current_attempts", + "github_actions:workflow_run:700" + ], + "equals": { + "ordinal": 2, + "terminal": false, + "state": "VERIFY_UNRESOLVED" + } + }, + { + "path": [ + "in_progress", + "steps", + 0, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "in_progress", + "steps", + 0, + "current_failed_count" + ], + "equals": 0 + }, + { + "path": [ + "queued", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "in_progress", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R30", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "queued", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T11:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "queued", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "in_progress", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T11:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "queued", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "queued", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "queued", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "queued", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "queued", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "queued", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "queued", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "queued", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "queued", + "steps", + 0, + "normalization", + "provenance", + "r", + "current_attempts", + "github_actions:workflow_run:700" + ], + "equals": { + "ordinal": 2, + "terminal": false, + "state": "VERIFY_UNRESOLVED" + } + }, + { + "path": [ + "queued", + "steps", + 0, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "queued", + "steps", + 0, + "current_failed_count" + ], + "equals": 0 + }, + { + "path": [ + "in_progress", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "in_progress", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "in_progress", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "in_progress", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "in_progress", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "in_progress", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "in_progress", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "in_progress", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "in_progress", + "steps", + 0, + "normalization", + "provenance", + "r", + "current_attempts", + "github_actions:workflow_run:700" + ], + "equals": { + "ordinal": 2, + "terminal": false, + "state": "VERIFY_UNRESOLVED" + } + }, + { + "path": [ + "in_progress", + "steps", + 0, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "in_progress", + "steps", + 0, + "current_failed_count" + ], + "equals": 0 + }, + { + "path": [ + "queued", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "in_progress", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R31", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "actions-only", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push", + "check_suite_id": 501 + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T11:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push", + "check_suite_id": 501 + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "checks-only", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "app": { + "id": 42, + "slug": "same-app" + } + } + }, + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T11:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "in_progress", + "conclusion": null, + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "combined", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push", + "check_suite_id": 501 + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T11:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push", + "check_suite_id": 501 + } + }, + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "combined-reverse", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "app": { + "id": 42, + "slug": "same-app" + } + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T11:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push", + "check_suite_id": 501 + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push", + "check_suite_id": 501 + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "newer-surface-disagreement", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push", + "check_suite_id": 501 + } + }, + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T11:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "in_progress", + "conclusion": null, + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "stale-only", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "STALE", + "native": { + "id": 501, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "actions-only", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "actions-only", + "steps", + 0, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "actions-only", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "actions-only", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "checks-only", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "checks-only", + "steps", + 0, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "checks-only", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "checks-only", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "combined", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "combined", + "steps", + 0, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "combined", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "combined", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "combined-reverse", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "combined-reverse", + "steps", + 0, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "combined-reverse", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "combined-reverse", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "newer-surface-disagreement", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "newer-surface-disagreement", + "steps", + 0, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "newer-surface-disagreement", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "newer-surface-disagreement", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "stale-only", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "stale-only", + "steps", + 0, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "stale-only", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "stale-only", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "actions-only", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "checks-only", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "combined", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "combined-reverse", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "newer-surface-disagreement", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "stale-only", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R32", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "pass-running", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "gitlab", + "forge_instance": "gitlab.example", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.gitlab.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "pipelines", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 101, + "sha": "r", + "status": "success", + "ref": "main", + "source": "push" + } + }, + { + "surface": "pipelines", + "revision": "r", + "observed_at": "2026-09-05T11:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 101, + "sha": "r", + "status": "running", + "ref": "main", + "source": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "fail-pending", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "gitlab", + "forge_instance": "gitlab.example", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.gitlab.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "pipelines", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 101, + "sha": "r", + "status": "failed", + "ref": "main", + "source": "push" + } + }, + { + "surface": "pipelines", + "revision": "r", + "observed_at": "2026-09-05T11:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 101, + "sha": "r", + "status": "pending", + "ref": "main", + "source": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "pass-running", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "pass-running", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "pass-running", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "UNKNOWN_HISTORY" + }, + { + "path": [ + "pass-running", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": null + }, + { + "path": [ + "pass-running", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "pass-running", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "pass-running", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "pass-running", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "pass-running", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "gitlab:pipeline:101" + ] + }, + { + "path": [ + "pass-running", + "steps", + 0, + "normalization", + "provenance", + "r", + "current_attempts", + "gitlab:pipeline:101" + ], + "equals": { + "ordinal": null, + "terminal": false, + "state": "VERIFY_UNRESOLVED" + } + }, + { + "path": [ + "pass-running", + "steps", + 0, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "fail-pending", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "fail-pending", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "fail-pending", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "fail-pending", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "fail-pending", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "fail-pending", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "fail-pending", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "fail-pending", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "fail-pending", + "steps", + 0, + "normalization", + "provenance", + "r", + "parent_ids" + ], + "equals": [ + "gitlab:pipeline:101" + ] + }, + { + "path": [ + "fail-pending", + "steps", + 0, + "normalization", + "provenance", + "r", + "current_attempts", + "gitlab:pipeline:101" + ], + "equals": { + "ordinal": null, + "terminal": false, + "state": "VERIFY_UNRESOLVED" + } + }, + { + "path": [ + "fail-pending", + "steps", + 0, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "pass-running", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "fail-pending", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R33", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "pass-fail-running", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 3, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "fail-pass-running", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 3, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "pass-fail-running", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "pass-fail-running", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "pass-fail-running", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "pass-fail-running", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "pass-fail-running", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "pass-fail-running", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "pass-fail-running", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "pass-fail-running", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "pass-fail-running", + "steps", + 0, + "normalization", + "provenance", + "r", + "current_attempts", + "github_actions:workflow_run:700" + ], + "equals": { + "ordinal": 3, + "terminal": false, + "state": "VERIFY_UNRESOLVED" + } + }, + { + "path": [ + "fail-pass-running", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "fail-pass-running", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "fail-pass-running", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "fail-pass-running", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "fail-pass-running", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "fail-pass-running", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "fail-pass-running", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "fail-pass-running", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "fail-pass-running", + "steps", + 0, + "normalization", + "provenance", + "r", + "current_attempts", + "github_actions:workflow_run:700" + ], + "equals": { + "ordinal": 3, + "terminal": false, + "state": "VERIFY_UNRESOLVED" + } + }, + { + "path": [ + "pass-fail-running", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "fail-pass-running", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R34", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "latest-unavailable", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "UNAVAILABLE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 3, + "status": null, + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "latest-contradictory", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 3, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 3, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "latest-unavailable", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "latest-unavailable", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "latest-unavailable", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "UNKNOWN_HISTORY" + }, + { + "path": [ + "latest-unavailable", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": null + }, + { + "path": [ + "latest-unavailable", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "latest-unavailable", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "latest-unavailable", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "latest-unavailable", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "latest-contradictory", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "latest-contradictory", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "latest-contradictory", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "latest-contradictory", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "latest-contradictory", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "latest-contradictory", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "latest-contradictory", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "latest-contradictory", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "latest-unavailable", + "steps", + 0, + "normalization", + "provenance", + "r", + "current_attempts", + "github_actions:workflow_run:700" + ], + "equals": { + "ordinal": 3, + "terminal": false, + "state": "UNKNOWN" + } + }, + { + "path": [ + "latest-unavailable", + "steps", + 0, + "normalization", + "coverage" + ], + "equals": "DEGRADED" + }, + { + "path": [ + "latest-contradictory", + "steps", + 0, + "normalization", + "provenance", + "r", + "current_attempts", + "github_actions:workflow_run:700" + ], + "equals": { + "ordinal": 3, + "terminal": false, + "state": "UNKNOWN" + } + }, + { + "path": [ + "latest-contradictory", + "steps", + 0, + "normalization", + "coverage" + ], + "equals": "DEGRADED" + }, + { + "path": [ + "latest-unavailable", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "latest-contradictory", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R35", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "forward", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 3, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "reverse", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 3, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "pages", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": { + "pages": [ + { + "ordinal": 1, + "has_next": true, + "items": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ] + }, + { + "ordinal": 2, + "has_next": false, + "items": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 3, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ] + } + ] + }, + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "forward", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "forward", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "forward", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "forward", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "forward", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "forward", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "forward", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "forward", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "forward", + "steps", + 0, + "normalization", + "provenance", + "r", + "current_attempts", + "github_actions:workflow_run:700" + ], + "equals": { + "ordinal": 3, + "terminal": false, + "state": "VERIFY_UNRESOLVED" + } + }, + { + "path": [ + "reverse", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "reverse", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "reverse", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "reverse", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "reverse", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "reverse", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "reverse", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "reverse", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "reverse", + "steps", + 0, + "normalization", + "provenance", + "r", + "current_attempts", + "github_actions:workflow_run:700" + ], + "equals": { + "ordinal": 3, + "terminal": false, + "state": "VERIFY_UNRESOLVED" + } + }, + { + "path": [ + "pages", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "pages", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "pages", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "pages", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "pages", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "pages", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "pages", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "pages", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "pages", + "steps", + 0, + "normalization", + "provenance", + "r", + "current_attempts", + "github_actions:workflow_run:700" + ], + "equals": { + "ordinal": 3, + "terminal": false, + "state": "VERIFY_UNRESOLVED" + } + }, + { + "path": [ + "forward", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "reverse", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "pages", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R36", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "unresolved-pass", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 701, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "unresolved-pass", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "unresolved-pass", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "unresolved-pass", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "PASS_ONLY_OBSERVED" + }, + { + "path": [ + "unresolved-pass", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "unresolved-pass", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "unresolved-pass", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "unresolved-pass", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "unresolved-pass", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "unresolved-pass", + "steps", + 0, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "unresolved-pass", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R37", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "normal", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 701, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "reverse", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 701, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "normal", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "normal", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "normal", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "normal", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "normal", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "normal", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "normal", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "normal", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "reverse", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "reverse", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "reverse", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "reverse", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "reverse", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "reverse", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "reverse", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "reverse", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "normal", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "reverse", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R38", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "github-linked", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push", + "check_suite_id": 501 + } + }, + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "app": { + "id": 42, + "slug": "same-app" + } + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 701, + "head_sha": "r", + "run_attempt": 1, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "gitlab", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "gitlab", + "forge_instance": "gitlab.example", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.gitlab.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "pipelines", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 101, + "sha": "r", + "status": "success", + "ref": "main", + "source": "push" + } + }, + { + "surface": "pipelines", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 102, + "sha": "r", + "status": "running", + "ref": "main", + "source": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "github-linked", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "github-linked", + "steps", + 0, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "github-linked", + "steps", + 0, + "normalization", + "coverage" + ], + "equals": "COMPLETE" + }, + { + "path": [ + "github-linked", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "github-linked", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "gitlab", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "gitlab", + "steps", + 0, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "gitlab", + "steps", + 0, + "normalization", + "coverage" + ], + "equals": "COMPLETE" + }, + { + "path": [ + "gitlab", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "gitlab", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "github-linked", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "gitlab", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R39", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "forward", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 701, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 702, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "cancelled", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "reverse", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 702, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "cancelled", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 701, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "shuffle", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 701, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 702, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "cancelled", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "pages", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": { + "pages": [ + { + "ordinal": 1, + "has_next": true, + "items": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 701, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ] + }, + { + "ordinal": 2, + "has_next": false, + "items": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 702, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "cancelled", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ] + } + ] + }, + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "forward", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "forward", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "forward", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "PASS_ONLY_OBSERVED" + }, + { + "path": [ + "forward", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "forward", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "forward", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "forward", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "forward", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "forward", + "steps", + 0, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "reverse", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "reverse", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "reverse", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "PASS_ONLY_OBSERVED" + }, + { + "path": [ + "reverse", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "reverse", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "reverse", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "reverse", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "reverse", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "reverse", + "steps", + 0, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "shuffle", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "shuffle", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "shuffle", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "PASS_ONLY_OBSERVED" + }, + { + "path": [ + "shuffle", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "shuffle", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "shuffle", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "shuffle", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "shuffle", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "shuffle", + "steps", + 0, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "pages", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "pages", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "pages", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "PASS_ONLY_OBSERVED" + }, + { + "path": [ + "pages", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "pages", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "pages", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "pages", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "pages", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "pages", + "steps", + 0, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "forward", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "reverse", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "shuffle", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "pages", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R40", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "unresolved-only", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "queued", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "unresolved-only", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "unresolved-only", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "unresolved-only", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "NO_DECISIVE_OBSERVED" + }, + { + "path": [ + "unresolved-only", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": null + }, + { + "path": [ + "unresolved-only", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "unresolved-only", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "unresolved-only", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "unresolved-only", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "unresolved-only", + "steps", + 0, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "unresolved-only", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R41", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "nondecisive-siblings", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 701, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "cancelled", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 702, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "skipped", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "nondecisive-siblings", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "nondecisive-siblings", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "nondecisive-siblings", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "NO_DECISIVE_OBSERVED" + }, + { + "path": [ + "nondecisive-siblings", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": null + }, + { + "path": [ + "nondecisive-siblings", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "nondecisive-siblings", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "nondecisive-siblings", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "nondecisive-siblings", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "nondecisive-siblings", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R42", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "unknown-fail-unresolved", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "future-value", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 701, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 702, + "head_sha": "r", + "run_attempt": 1, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "unknown-fail-unresolved", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "unknown-fail-unresolved", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "unknown-fail-unresolved", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "unknown-fail-unresolved", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "unknown-fail-unresolved", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "unknown-fail-unresolved", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "unknown-fail-unresolved", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "unknown-fail-unresolved", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "unknown-fail-unresolved", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R43", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "lifecycle", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + }, + { + "history": "CONTINUE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-06T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-06T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + }, + { + "history": "CONTINUE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-07T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-07T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 3, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "lifecycle", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "PASS_ONLY_OBSERVED" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "integrity", + "band" + ], + "equals": "SPARSE" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "current_decisive_count" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "history", + "r", + "history_state" + ], + "equals": "PASS_ONLY_OBSERVED" + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "history", + "r", + "history_state" + ], + "equals": "PASS_ONLY_OBSERVED" + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "integrity", + "band" + ], + "equals": "SPARSE" + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "current_decisive_count" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "verified" + ], + "equals": true + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R44", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "lifecycle", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + }, + { + "history": "CONTINUE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-06T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-06T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "lifecycle", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "current_decisive_count" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R45", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "lifecycle", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + }, + { + "history": "CONTINUE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-06T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-06T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "lifecycle", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "PASS_ONLY_OBSERVED" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "integrity", + "band" + ], + "equals": "SPARSE" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "current_decisive_count" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "history", + "r", + "history_state" + ], + "equals": "PASS_ONLY_OBSERVED" + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R46", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "lifecycle", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + }, + { + "history": "CONTINUE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-06T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-06T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + }, + { + "history": "CONTINUE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-07T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-07T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 3, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "lifecycle", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "current_decisive_count" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "integrity", + "band" + ], + "equals": "SPARSE_MIXED" + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "current_decisive_count" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "integrity", + "band" + ], + "equals": "SPARSE_MIXED" + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "current_decisive_count" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "verified" + ], + "equals": true + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R47", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "lifecycle", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + }, + { + "history": "CONTINUE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-06T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-06T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + }, + { + "history": "CONTINUE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-07T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-07T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 3, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "lifecycle", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "PASS_ONLY_OBSERVED" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "integrity", + "band" + ], + "equals": "SPARSE" + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "current_decisive_count" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "current_decisive_count" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "integrity", + "band" + ], + "equals": "SPARSE_MIXED" + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "current_decisive_count" + ], + "equals": 1 + }, + { + "path": [ + "lifecycle", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "lifecycle", + "steps", + 1, + "verified" + ], + "equals": true + }, + { + "path": [ + "lifecycle", + "steps", + 2, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R48", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "actions", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + }, + { + "history": "CONTINUE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-06T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-06T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "checks", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "completed", + "conclusion": "failure", + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ], + "children": [], + "rerun_links": [] + } + }, + { + "history": "CONTINUE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-06T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-06T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "in_progress", + "conclusion": null, + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "linked", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push", + "check_suite_id": 501 + } + }, + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "completed", + "conclusion": "failure", + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ], + "children": [], + "rerun_links": [] + } + }, + { + "history": "CONTINUE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-06T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-06T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push", + "check_suite_id": 501 + } + }, + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-06T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "in_progress", + "conclusion": null, + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "gitlab", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "gitlab", + "forge_instance": "gitlab.example", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.gitlab.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "pipelines", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 101, + "sha": "r", + "status": "failed", + "ref": "main", + "source": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + }, + { + "history": "CONTINUE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "gitlab", + "forge_instance": "gitlab.example", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.gitlab.v1", + "observed_at": "2026-09-06T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "pipelines", + "revision": "r", + "observed_at": "2026-09-06T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 101, + "sha": "r", + "status": "running", + "ref": "main", + "source": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "actions", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "actions", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "actions", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "actions", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "actions", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "actions", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "actions", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "actions", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "actions", + "steps", + 1, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "actions", + "steps", + 1, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "actions", + "steps", + 1, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "actions", + "steps", + 1, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "actions", + "steps", + 1, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "actions", + "steps", + 1, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "actions", + "steps", + 1, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "actions", + "steps", + 1, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "checks", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "checks", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "checks", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "checks", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "checks", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "checks", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "checks", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "checks", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "checks", + "steps", + 1, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "checks", + "steps", + 1, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "checks", + "steps", + 1, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "checks", + "steps", + 1, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "checks", + "steps", + 1, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "checks", + "steps", + 1, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "checks", + "steps", + 1, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "checks", + "steps", + 1, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "linked", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "linked", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "linked", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "linked", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "linked", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "linked", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "linked", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "linked", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "linked", + "steps", + 1, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "linked", + "steps", + 1, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "linked", + "steps", + 1, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "linked", + "steps", + 1, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "linked", + "steps", + 1, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "linked", + "steps", + 1, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "linked", + "steps", + 1, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "linked", + "steps", + 1, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "gitlab", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "gitlab", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "gitlab", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "gitlab", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "gitlab", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "gitlab", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "gitlab", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "gitlab", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "gitlab", + "steps", + 1, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "gitlab", + "steps", + 1, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "gitlab", + "steps", + 1, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "gitlab", + "steps", + 1, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "gitlab", + "steps", + 1, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "gitlab", + "steps", + 1, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "gitlab", + "steps", + 1, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "gitlab", + "steps", + 1, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "actions", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "actions", + "steps", + 1, + "verified" + ], + "equals": true + }, + { + "path": [ + "checks", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "checks", + "steps", + 1, + "verified" + ], + "equals": true + }, + { + "path": [ + "linked", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "linked", + "steps", + 1, + "verified" + ], + "equals": true + }, + { + "path": [ + "gitlab", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "gitlab", + "steps", + 1, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R49", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "forward", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + }, + { + "history": "CONTINUE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-06T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-06T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-06T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 701, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "reverse", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + }, + { + "history": "CONTINUE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-06T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-06T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 701, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-06T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "shuffle", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + }, + { + "history": "CONTINUE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-06T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-06T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-06T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 701, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "pages", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + }, + { + "history": "CONTINUE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-06T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": { + "pages": [ + { + "ordinal": 1, + "has_next": true, + "items": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + }, + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-06T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ] + }, + { + "ordinal": 2, + "has_next": false, + "items": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-06T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 701, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ] + } + ] + }, + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "forward", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "forward", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "forward", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "forward", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "forward", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "forward", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "forward", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "forward", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "forward", + "steps", + 1, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "forward", + "steps", + 1, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "forward", + "steps", + 1, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "forward", + "steps", + 1, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "forward", + "steps", + 1, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "forward", + "steps", + 1, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "forward", + "steps", + 1, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "forward", + "steps", + 1, + "integrity", + "band" + ], + "equals": "SPARSE_MIXED" + }, + { + "path": [ + "reverse", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "reverse", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "reverse", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "reverse", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "reverse", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "reverse", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "reverse", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "reverse", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "reverse", + "steps", + 1, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "reverse", + "steps", + 1, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "reverse", + "steps", + 1, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "reverse", + "steps", + 1, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "reverse", + "steps", + 1, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "reverse", + "steps", + 1, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "reverse", + "steps", + 1, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "reverse", + "steps", + 1, + "integrity", + "band" + ], + "equals": "SPARSE_MIXED" + }, + { + "path": [ + "shuffle", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "shuffle", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "shuffle", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "shuffle", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "shuffle", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "shuffle", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "shuffle", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "shuffle", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "shuffle", + "steps", + 1, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "shuffle", + "steps", + 1, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "shuffle", + "steps", + 1, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "shuffle", + "steps", + 1, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "shuffle", + "steps", + 1, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "shuffle", + "steps", + 1, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "shuffle", + "steps", + 1, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "shuffle", + "steps", + 1, + "integrity", + "band" + ], + "equals": "SPARSE_MIXED" + }, + { + "path": [ + "pages", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "pages", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "pages", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "pages", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "pages", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "pages", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "pages", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "pages", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "pages", + "steps", + 1, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "pages", + "steps", + 1, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "pages", + "steps", + 1, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "pages", + "steps", + 1, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "pages", + "steps", + 1, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "pages", + "steps", + 1, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "pages", + "steps", + 1, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "pages", + "steps", + 1, + "integrity", + "band" + ], + "equals": "SPARSE_MIXED" + }, + { + "path": [ + "forward", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "forward", + "steps", + 1, + "verified" + ], + "equals": true + }, + { + "path": [ + "reverse", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "reverse", + "steps", + 1, + "verified" + ], + "equals": true + }, + { + "path": [ + "shuffle", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "shuffle", + "steps", + 1, + "verified" + ], + "equals": true + }, + { + "path": [ + "pages", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "pages", + "steps", + 1, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R50", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "no-decisive-history", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "no-decisive-history", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "no-decisive-history", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "no-decisive-history", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "NO_DECISIVE_OBSERVED" + }, + { + "path": [ + "no-decisive-history", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": null + }, + { + "path": [ + "no-decisive-history", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "no-decisive-history", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "no-decisive-history", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "no-decisive-history", + "steps", + 0, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "no-decisive-history", + "steps", + 0, + "current_decisive_count" + ], + "equals": 0 + }, + { + "path": [ + "no-decisive-history", + "steps", + 0, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R51", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "window", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-08-29T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + }, + { + "history": "CONTINUE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-06T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-08-29T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-06T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + }, + { + "history": "CONTINUE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-13T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-08-29T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-13T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "in_progress", + "conclusion": null, + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "window", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "window", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "window", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "window", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "window", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "window", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "window", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "window", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "window", + "steps", + 1, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "window", + "steps", + 1, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_UNRESOLVED" + }, + { + "path": [ + "window", + "steps", + 1, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "window", + "steps", + 1, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "window", + "steps", + 1, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "window", + "steps", + 1, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "window", + "steps", + 1, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "window", + "steps", + 1, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "window", + "steps", + 2, + "current" + ], + "equals": {} + }, + { + "path": [ + "window", + "steps", + 2, + "history" + ], + "equals": {} + }, + { + "path": [ + "window", + "steps", + 2, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "window", + "steps", + 2, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "window", + "steps", + 2, + "normalization", + "excluded_revisions" + ], + "equals": [ + { + "revision": "r", + "committed_at": "2026-08-29T00:00:00Z", + "default_branch": true, + "reason": "OUTSIDE_WINDOW" + } + ] + }, + { + "path": [ + "window", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "window", + "steps", + 1, + "verified" + ], + "equals": true + }, + { + "path": [ + "window", + "steps", + 2, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R52", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "lost-predecessor", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "completed", + "conclusion": "failure", + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ], + "children": [], + "rerun_links": [] + } + }, + { + "history": "GAP", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-06T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "checks", + "revision": "r", + "observed_at": "2026-09-06T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 501, + "head_sha": "r", + "status": "completed", + "conclusion": "success", + "app": { + "id": 42, + "slug": "same-app" + } + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "lost-predecessor", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "lost-predecessor", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "lost-predecessor", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "lost-predecessor", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "lost-predecessor", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "lost-predecessor", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "lost-predecessor", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "lost-predecessor", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "lost-predecessor", + "steps", + 1, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "lost-predecessor", + "steps", + 1, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "lost-predecessor", + "steps", + 1, + "history", + "r", + "history_state" + ], + "equals": "UNKNOWN_HISTORY" + }, + { + "path": [ + "lost-predecessor", + "steps", + 1, + "history", + "r", + "historical_contribution" + ], + "equals": null + }, + { + "path": [ + "lost-predecessor", + "steps", + 1, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "lost-predecessor", + "steps", + 1, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "lost-predecessor", + "steps", + 1, + "integrity", + "evaluation_status" + ], + "equals": "UNKNOWN" + }, + { + "path": [ + "lost-predecessor", + "steps", + 1, + "integrity", + "band" + ], + "equals": null + }, + { + "path": [ + "lost-predecessor", + "steps", + 1, + "comparison" + ], + "equals": "HISTORY_GAP" + }, + { + "path": [ + "lost-predecessor", + "steps", + 1, + "integrity", + "derived", + "revision_history", + "source", + "status" + ], + "equals": "HISTORY_GAP" + }, + { + "path": [ + "lost-predecessor", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "lost-predecessor", + "steps", + 1, + "verified" + ], + "equals": true + } + ] + }, + { + "case": "R53", + "scenario": "ci-revisions.v1", + "given": { + "variants": [ + { + "name": "pass-pass", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + }, + { + "history": "CONTINUE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-06T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-06T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "completed", + "conclusion": "success", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + }, + { + "name": "fail-fail", + "steps": [ + { + "history": "BASELINE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-05T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-05T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 1, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + }, + { + "history": "CONTINUE", + "evidence": { + "contract": "devostasis.ci-normalization.v1", + "project": { + "provider": "github", + "forge_instance": "github.com", + "immutable_project_id": "123456", + "default_branch": "master" + }, + "outcome_map": "devostasis.ci-outcomes.github.v1", + "observed_at": "2026-09-06T12:00:00Z", + "configured": true, + "coverage": "COMPLETE", + "revisions": [ + { + "revision": "r", + "committed_at": "2026-09-04T00:00:00Z", + "default_branch": true + } + ], + "parents": [ + { + "surface": "actions", + "revision": "r", + "observed_at": "2026-09-06T10:00:00Z", + "coverage": "COMPLETE", + "native": { + "id": 700, + "head_sha": "r", + "run_attempt": 2, + "status": "completed", + "conclusion": "failure", + "name": "same CI name", + "workflow_id": 42, + "event": "push" + } + } + ], + "children": [], + "rerun_links": [] + } + } + ] + } + ] + }, + "checks": [ + { + "path": [ + "pass-pass", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "pass-pass", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "pass-pass", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "PASS_ONLY_OBSERVED" + }, + { + "path": [ + "pass-pass", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "pass-pass", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "pass-pass", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "pass-pass", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "pass-pass", + "steps", + 0, + "integrity", + "band" + ], + "equals": "SPARSE" + }, + { + "path": [ + "pass-pass", + "steps", + 1, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "pass-pass", + "steps", + 1, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "pass-pass", + "steps", + 1, + "history", + "r", + "history_state" + ], + "equals": "PASS_ONLY_OBSERVED" + }, + { + "path": [ + "pass-pass", + "steps", + 1, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_PASS" + }, + { + "path": [ + "pass-pass", + "steps", + 1, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "pass-pass", + "steps", + 1, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 0 + }, + { + "path": [ + "pass-pass", + "steps", + 1, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "pass-pass", + "steps", + 1, + "integrity", + "band" + ], + "equals": "SPARSE" + }, + { + "path": [ + "fail-fail", + "steps", + 0, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "fail-fail", + "steps", + 0, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "fail-fail", + "steps", + 0, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "fail-fail", + "steps", + 0, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "fail-fail", + "steps", + 0, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "fail-fail", + "steps", + 0, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "fail-fail", + "steps", + 0, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "fail-fail", + "steps", + 0, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "fail-fail", + "steps", + 1, + "current_revision_count" + ], + "equals": 1 + }, + { + "path": [ + "fail-fail", + "steps", + 1, + "current", + "r", + "current_verdict" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "fail-fail", + "steps", + 1, + "history", + "r", + "history_state" + ], + "equals": "FAILURE_OBSERVED" + }, + { + "path": [ + "fail-fail", + "steps", + 1, + "history", + "r", + "historical_contribution" + ], + "equals": "VERIFY_FAIL" + }, + { + "path": [ + "fail-fail", + "steps", + 1, + "integrity", + "derived", + "decisive_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "fail-fail", + "steps", + 1, + "integrity", + "derived", + "failed_count_14d" + ], + "equals": 1 + }, + { + "path": [ + "fail-fail", + "steps", + 1, + "integrity", + "evaluation_status" + ], + "equals": "AVAILABLE" + }, + { + "path": [ + "fail-fail", + "steps", + 1, + "integrity", + "band" + ], + "equals": "FAILING" + }, + { + "path": [ + "pass-pass", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "pass-pass", + "steps", + 1, + "verified" + ], + "equals": true + }, + { + "path": [ + "fail-fail", + "steps", + 0, + "verified" + ], + "equals": true + }, + { + "path": [ + "fail-fail", + "steps", + 1, + "verified" + ], + "equals": true + } + ] + } + ] +} diff --git a/tests/surface_bridge.py b/tests/surface_bridge.py index 2b5a09b..7f2cdad 100644 --- a/tests/surface_bridge.py +++ b/tests/surface_bridge.py @@ -18,6 +18,7 @@ from devostasis.runner import build_from_observations from devostasis.vitals import build_snapshot, integrity from helpers import add, finalize, full_inputs, integrity_inputs, obs_set +from ci_surface_bridge import consumer as vital_consumers, revisions as ci_revisions CONTRACT = "devostasis.conformance-bridge.v1" BUNDLE_LINEAGE = {"schema": "devostasis.manifest.v2", "artifact_contract_version": "devostasis.bundle.v3", @@ -236,7 +237,8 @@ def read(file, *args, **kwargs): SCENARIOS = {"ci-native.v1": native, "independence.v1": independence, - "multi-vital.v1": multivital, "bundle.v1": bundle, "history-store.v1": history} + "multi-vital.v1": multivital, "bundle.v1": bundle, "history-store.v1": history, + "vital-consumers.v1": vital_consumers, "ci-revisions.v1": ci_revisions} def load(path): @@ -244,10 +246,12 @@ def load(path): shape(document, ("schema", "source", "cases")) if document["schema"] != CONTRACT: raise ValueError("unknown bridge lineage") if not isinstance(document["source"], str) or not document["source"]: raise ValueError("source required") + if not isinstance(document["cases"], list) or not document["cases"]: + raise ValueError("nonempty case inventory required") seen = set() for case in document["cases"]: shape(case, ("case", "scenario", "given", "checks")) - if not isinstance(case["case"], str) or case["case"] in seen: raise ValueError("duplicate or invalid case") + if not isinstance(case["case"], str) or not case["case"] or case["case"] in seen: raise ValueError("duplicate or invalid case") seen.add(case["case"]) if case["scenario"] not in SCENARIOS: raise ValueError("unknown scenario lineage") if not isinstance(case["given"], dict) or not isinstance(case["checks"], list) or not case["checks"]: @@ -275,13 +279,24 @@ def execute(case, root): except (KeyError, IndexError, TypeError): exists = False; break where = f"{case['case']}:{check['path']}" if "absent" in check: - assert not exists, f"{where}: forbidden field exists" + if exists: raise AssertionError(f"{where}: forbidden field exists") continue - assert exists, f"{where}: unknown checkpoint/assertion path" + if not exists: raise AssertionError(f"{where}: unknown checkpoint/assertion path") if "equals" in check: - assert canonical.canonical_bytes(node) == canonical.canonical_bytes(check["equals"]), f"{where}: expected {check['equals']!r}, got {node!r}" + if canonical.canonical_bytes(node) != canonical.canonical_bytes(check["equals"]): + raise AssertionError(f"{where}: expected {check['equals']!r}, got {node!r}") if "not_equals" in check: - assert canonical.canonical_bytes(node) != canonical.canonical_bytes(check["not_equals"]), f"{where}: forbidden value {node!r}" + if canonical.canonical_bytes(node) == canonical.canonical_bytes(check["not_equals"]): + raise AssertionError(f"{where}: forbidden value {node!r}") if "contains" in check: - assert check["contains"] in node, f"{where}: missing {check['contains']!r}" + if check["contains"] not in node: raise AssertionError(f"{where}: missing {check['contains']!r}") return actual + + +def load_suite(paths=None): + paths = paths if paths is not None else [Path(__file__).parent / "fixtures" / name + for name in ("conformance-surface.json", "ci-revisions.json")] + cases = [case for path in paths for case in load(path)] + ids = [case["case"] for case in cases] + if len(ids) != len(set(ids)): raise ValueError("duplicate permanent case across bridge carriers") + return cases diff --git a/tests/test_ci_normalization.py b/tests/test_ci_normalization.py new file mode 100644 index 0000000..245aaf6 --- /dev/null +++ b/tests/test_ci_normalization.py @@ -0,0 +1,157 @@ +"""Admission attacks and compatibility at the opt-in native CI boundary.""" + +from copy import deepcopy +from dataclasses import replace +from pathlib import Path +import subprocess +import sys + +import pytest + +from devostasis import canonical +from devostasis.adapters import ci +from devostasis.delta import compare +from devostasis.vitals import integrity +from helpers import SUBJECT, obs_set, integrity_inputs, passing_revisions +from surface_bridge import load, load_suite + +FIXTURE = Path(__file__).parent / "fixtures" / "ci-revisions.json" + + +def evidence(): + case = next(c for c in load(FIXTURE) if c["case"] == "R26") + return deepcopy(case["given"]["variants"][0]["steps"][0]["evidence"]) + + +def observed(value=None): + value = evidence() if value is None else value + return ci.observations(value, {**SUBJECT, **value["project"]}) + + +@pytest.mark.parametrize("key,field,value", [ + (ci.NATIVE, "provider", "gitlab"), + (ci.NATIVE, "adapter_version", "future"), + (ci.NATIVE, "source_ref", "native-ci:forged"), + (ci.PROVENANCE, "collected_at", "2026-09-01T00:00:00Z"), + (ci.PROVENANCE, "value", {}), + ("ci.revision_verdicts_14d", "value", []), + ("ci.revision_verdicts_14d", "freshness", "STALE"), + ("ci.configured", "value", False), +]) +def test_governed_integrity_refuses_forged_native_projections(key, field, value): + obs = observed() + obs.replace(replace(obs.get(key), **{field: value})) + with pytest.raises(ValueError, match="does not replay"): + integrity.evaluate(obs) + + +@pytest.mark.parametrize("change", ["contract", "mapping", "provider", "extra", "native-extra", "sha", "id", "attempt", "name", "future", "parent-binding", "revision-binding", "missing-page", "duplicate-page", "continuation"]) +def test_native_input_unknown_shapes_and_ambiguous_bindings_fail_closed(change): + value = evidence() + first = value["parents"][0] + if change == "contract": value["contract"] += "+future" + elif change == "mapping": value["outcome_map"] = "devostasis.ci-outcomes.gitlab.v1" + elif change == "provider": value["project"]["provider"] = "future" + elif change == "extra": value["ignored"] = True + elif change == "native-extra": first["native"]["ignored"] = True + elif change == "sha": first["native"]["head_sha"] = "foreign" + elif change == "id": first["native"]["id"] = True + elif change == "attempt": first["native"]["run_attempt"] = 0 + elif change == "name": first["native"]["name"] = 42 + elif change == "future": first["observed_at"] = "2099-01-01T00:00:00Z" + elif change in ("parent-binding", "revision-binding"): + value["revisions"].append({**value["revisions"][0], "revision": "foreign"}) + row = deepcopy(first); row["revision"] = row["native"]["head_sha"] = "foreign" + if change == "parent-binding": value["parents"].append(row) + else: value["revisions"].append({**value["revisions"][0], "committed_at": "2026-09-01T00:00:00Z"}) + else: + page = {"ordinal": 1, "has_next": False, "items": value["parents"]} + value["parents"] = {"pages": [page]} + if change == "missing-page": page["ordinal"] = 2 + elif change == "duplicate-page": value["parents"]["pages"].append(deepcopy(page)) + else: value["parents"]["pages"].append({"ordinal": 2, "has_next": False, "items": []}) + with pytest.raises(ValueError): ci.normalize(value) + + +def test_subject_and_observation_anchor_are_bound_to_native_evidence(): + value = evidence() + with pytest.raises(ValueError, match="project/subject"): + ci.observations(value, {**SUBJECT, "immutable_project_id": "foreign"}) + obs = observed(value); obs.observed_at = "2026-09-04T00:00:00Z" + with pytest.raises(ValueError, match="observation-time"): + integrity.evaluate(obs) + + +def test_page_transport_and_enumeration_do_not_change_canonical_observations(): + value = evidence(); before = deepcopy(value) + alternate = deepcopy(value) + alternate["parents"] = {"pages": [ + {"ordinal": 2, "has_next": False, "items": [value["parents"][0]]}, + {"ordinal": 1, "has_next": True, "items": list(reversed(value["parents"]))}, + ]} + a, b = observed(value), observed(alternate) + assert canonical.canonical_bytes(a.to_dict()) == canonical.canonical_bytes(b.to_dict()) + assert integrity.evaluate(a).to_dict() == integrity.evaluate(b).to_dict() + assert value == before + + +def test_partial_inventory_and_partial_parent_cannot_prove_favorable_history(): + value = evidence() + # One observed passing attempt would be complete without the partial flag. + row = deepcopy(value["parents"][-1]); row["native"]["run_attempt"] = 1 + row["native"].update(status="completed", conclusion="success") + value["parents"] = [row] + value["parents"][0]["coverage"] = "PARTIAL" + normalized = ci.normalize(value) + assert normalized["records"][0]["history_state"] == "UNKNOWN_HISTORY" + assert integrity.evaluate(observed(value)).evaluation_status == "UNKNOWN" + row["coverage"] = "COMPLETE" + value["parents"] = {"pages": [{"ordinal": 1, "has_next": True, "items": [row]}]} + obs = observed(value) + assert obs.get("ci.revision_verdicts_14d").status == "PARTIAL" + assert integrity.evaluate(obs).evaluation_status == "UNKNOWN" + + +def test_unknown_native_outcome_is_unknown_and_child_current_uses_latest_snapshot(): + value = evidence() + for row in value["parents"]: row["native"]["status"] = "future" + assert ci.normalize(value)["records"][0]["current_verdict"] == "UNKNOWN" + case = next(c for c in load(FIXTURE) if c["case"] == "R17") + value = deepcopy(case["given"]["variants"][0]["steps"][0]["evidence"]) + old = value["children"][0]; value["children"] = [old] + old["observed_at"] = "2026-09-04T00:00:00Z" + newer = deepcopy(old); newer["observed_at"] = value["observed_at"] + newer["native"].update(status="in_progress", conclusion=None) + value["children"].append(newer) + result = ci.normalize(value) + assert result["records"][0]["current_verdict"] == "VERIFY_UNRESOLVED" + assert result["records"][0]["history_state"] == "UNKNOWN_HISTORY" + + +def test_normalization_is_opt_in_and_delta_names_the_rule_boundary(): + old = obs_set(); integrity_inputs(old, True, passing_revisions(4)) + previous = integrity.evaluate(old).to_dict() + assert previous["rule_id"] == integrity.RULE_ID + assert "ci_normalization" not in previous["derived"] + current = integrity.evaluate(observed()).to_dict() + assert current["rule_id"] == integrity.RULE_ID + "+ci-normalization-001" + delta = compare({"vitals": [current]}, {"vitals": [previous]}, "COMPARABLE", "previous") + row = next(r for r in delta["vitals"] if r["vital_id"] == "integrity") + assert row["transition_class"] == "INCOMPARABLE" + assert row["reason_codes"][0].startswith("RULE_VERSION_BOUNDARY:") + + +def test_all_r11_r53_have_one_permanent_case_and_required_matrix_variants(): + cases = load(FIXTURE) + assert {c["case"] for c in cases} == {f"R{i}" for i in range(11, 54)} + assert len(cases) == 43 + assert sum(len(c["given"]["variants"]) for c in cases if c["scenario"] == "ci-revisions.v1") == 84 + # Cross-carrier duplication must never become two competing meanings. + with pytest.raises(ValueError, match="across bridge carriers"): + load_suite([FIXTURE, FIXTURE]) + + +def test_bridge_wrong_assertion_still_fails_with_optimized_python(tmp_path): + code = "from surface_bridge import execute; from pathlib import Path; execute({'case':'negative','scenario':'ci-native.v1','given':{'variants':[{'name':'x','provider':'github','mapping':'devostasis.ci-outcomes.github.v1','status':'completed','conclusion':'success'}]},'checks':[{'path':[],'equals':None}]}, Path('.'))" + result = subprocess.run([sys.executable, "-O", "-c", code], cwd=Path(__file__).parent, capture_output=True, text=True) + assert result.returncode != 0 and "AssertionError" in result.stderr diff --git a/tests/test_ci_surface_schema.py b/tests/test_ci_surface_schema.py new file mode 100644 index 0000000..cff4134 --- /dev/null +++ b/tests/test_ci_surface_schema.py @@ -0,0 +1,47 @@ +"""Published closed schemas are checked by a real Draft 2020-12 validator.""" + +from copy import deepcopy +from pathlib import Path + +from jsonschema import Draft202012Validator, ValidationError +from referencing import Registry, Resource +import pytest + +from devostasis import canonical + +ROOT = Path(__file__).resolve().parents[1] +SCHEMAS = ROOT / "schemas" +FILES = ("ci-normalization.schema.json", "conformance-bridge.schema.json") + + +def validator(name): + resources = [canonical.load_file(SCHEMAS / file) for file in FILES] + registry = Registry().with_resources((r["$id"], Resource.from_contents(r)) for r in resources) + return Draft202012Validator(canonical.load_file(SCHEMAS / name), registry=registry) + + +@pytest.mark.parametrize("name", FILES) +def test_ci_surface_schemas_are_valid_draft_2020_12(name): + Draft202012Validator.check_schema(canonical.load_file(SCHEMAS / name)) + + +@pytest.mark.parametrize("name", ["conformance-surface.json", "ci-revisions.json"]) +def test_published_bridge_schema_accepts_every_literal_case(name): + document = canonical.load_file(ROOT / "tests" / "fixtures" / name) + validator("conformance-bridge.schema.json").validate(document) + + +@pytest.mark.parametrize("change", ["lineage", "scenario", "step", "native", "extra", "check"]) +def test_published_bridge_schema_refuses_unknown_native_and_operation_fields(change): + document = canonical.load_file(ROOT / "tests/fixtures/ci-revisions.json") + case = next(c for c in document["cases"] if c["case"] == "R26") + document["cases"] = [deepcopy(case)]; case = document["cases"][0] + step = case["given"]["variants"][0]["steps"][0] + if change == "lineage": step["evidence"]["contract"] += "+future" + elif change == "scenario": case["scenario"] += "+future" + elif change == "step": step["history"] = "SYNTHETIC_PREDECESSOR" + elif change == "native": step["evidence"]["parents"][0]["native"]["ignored"] = True + elif change == "extra": case["given"]["ignored"] = True + else: case["checks"][0]["ignored"] = True + with pytest.raises(ValidationError): + validator("conformance-bridge.schema.json").validate(document) diff --git a/tests/test_conformance_surface.py b/tests/test_conformance_surface.py index b56588f..65b4767 100644 --- a/tests/test_conformance_surface.py +++ b/tests/test_conformance_surface.py @@ -6,22 +6,22 @@ import pytest from devostasis import canonical, vectors -from surface_bridge import execute, load +from surface_bridge import execute, load, load_suite CASES = Path(__file__).parent / "fixtures" / "conformance-surface.json" -@pytest.mark.parametrize("case", load(CASES), ids=lambda c: c["case"]) +@pytest.mark.parametrize("case", load_suite(), ids=lambda c: c["case"]) def test_conformance_surface(case, tmp_path): execute(case, tmp_path) def test_bridge_and_v1_do_not_duplicate_permanent_ids(): old = {v.case for v in vectors.load([Path(__file__).parent / "vectors"])} - assert not old.intersection(c["case"] for c in load(CASES)) + assert not old.intersection(c["case"] for c in load_suite()) -@pytest.mark.parametrize("mutation", ["schema", "scenario", "check", "case", "extra"]) +@pytest.mark.parametrize("mutation", ["schema", "scenario", "check", "case", "extra", "empty"]) def test_bridge_unknown_and_duplicate_lineage_never_skips(tmp_path, mutation): document = canonical.loads(CASES.read_text(encoding="utf-8")) if mutation == "schema": document["schema"] += "+future" @@ -29,6 +29,7 @@ def test_bridge_unknown_and_duplicate_lineage_never_skips(tmp_path, mutation): if mutation == "check": document["cases"][0]["checks"][0]["ignored"] = True if mutation == "case": document["cases"].append(document["cases"][0]) if mutation == "extra": document["ignored"] = True + if mutation == "empty": document["cases"] = [] file = tmp_path / "bad.json"; file.write_bytes(canonical.canonical_bytes(document)) with pytest.raises(ValueError): load(file) diff --git a/tests/test_spec_drift.py b/tests/test_spec_drift.py index 1f75b07..bbe191d 100644 --- a/tests/test_spec_drift.py +++ b/tests/test_spec_drift.py @@ -61,8 +61,8 @@ def test_the_specification_index_lists_every_specification_page(): def test_the_conformance_table_and_bridge_name_the_same_cases(): - from surface_bridge import load - corpus = {case["case"] for case in load(ROOT / "tests/fixtures/conformance-surface.json")} + from surface_bridge import load_suite + corpus = {case["case"] for case in load_suite()} cited = set(BRIDGE_CITATION.findall(CONFORMANCE.read_text(encoding="utf-8"))) assert cited == corpus