diff --git a/.crab.yml b/.crab.yml new file mode 100644 index 0000000..ad83a9e --- /dev/null +++ b/.crab.yml @@ -0,0 +1,45 @@ +# Hungry Crab maw configuration. Every key is optional; these are the defaults. +license: null # SPDX id of this repository; detected from LICENSE when null +mode: normal # normal | strict. Strict downgrades COPY to REIMPLEMENT for + # code and copies only configs and templates. Not enforced + # yet: today the setting is accepted and ignored. +hunger: # per nutrient category: true | false | issues-only | ideas-only + security: true + ci: true + tests: true + tooling: true + ai-config: true + hygiene: true + docs: true + deps: true + history-lesson: true + issue-lesson: true + architecture: issues-only + code: ideas-only # declared, and produced by nothing until 0.4: accepted and inert +ignore: [] # globs excluded from this repository's own digest, so that test + # fixtures and vendored trees are not mistaken for your code, e.g. + # [tests/fixtures/**, examples/**]. Patterns are case-sensitive on + # every platform, like the git paths they match. +budget: + policy: warn # warn | enforce | off for the whole digest Markdown budget +serve: + issues: ask # auto | ask | off + prs: ask # auto | ask | off; gates --as pr-branch (REIMPLEMENT and COPY) + max_prs_per_run: 3 + labels: [hungry-crab] + assignees: [] + token_env: "" # environment variable holding the token to file issues as, e.g. + # CRAB_BOT_TOKEN with a GitHub App installation token. Empty means + # gh's own login: your issues carry your name, not the crab's. +trust: # a license is a promise to strangers; these are not strangers + same_owner: true # prey owned by the account that owns this repository is your own code + owners: [] # other accounts whose repositories count as your own, e.g. [acme-inc] + bypass_license: false # last resort: treat every prey as COPY. Every card says so, and the + # verdict is flagged for human review, because this is not a finding + # about the license but a decision to stop asking. +attribution_file: THIRD_PARTY_NOTICES.md + # where COPY records its sources: rendered by `crab attribution` + # from the receipts in .crab/attributions.json, which + # `crab serve --as pr-branch` writes when it carries prey files in. +ledger: repo # repo (.crab/ledger.json, committed) | cache | none +scoring: {} # overrides for data/scoring.yml sections; `crab tune` suggests them diff --git a/.crab/attributions.json b/.crab/attributions.json new file mode 100644 index 0000000..3a115e0 --- /dev/null +++ b/.crab/attributions.json @@ -0,0 +1,53 @@ +{ + "schema": "hungry-crab.attributions/1", + "receipts": [ + { + "nutrient_id": "crab:ci:ci.concurrency", + "source": { + "label": "pypa/build", + "url": "https://github.com/pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "license": "MIT" + }, + "taken": [ + { + "maw_path": ".github/workflows/ci.yml", + "prey_path": ".github/workflows/test.yml", + "verbatim": false + } + ], + "mode": "COPY", + "obligation": { + "kind": "copyright-notice", + "text": "keep the source's copyright and permission notice with the material" + }, + "summary": "carried the concurrency block of pypa/build's test workflow into ci.yml, keyed on the ref with cancel-in-progress, so a push supersedes the run of the commit it replaced; nothing else in the workflow changed", + "branch": "crab/ci-ci.concurrency-a444a227", + "recorded_at": "2026-09-25T17:51:47+00:00" + }, + { + "nutrient_id": "crab:tooling:tooling.dependabot", + "source": { + "label": "pypa/build", + "url": "https://github.com/pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "license": "MIT" + }, + "taken": [ + { + "maw_path": ".github/dependabot.yml", + "prey_path": ".github/dependabot.yml", + "verbatim": false + } + ], + "mode": "COPY", + "obligation": { + "kind": "copyright-notice", + "text": "keep the source's copyright and permission notice with the material" + }, + "summary": "carried pypa/build's github-actions Dependabot block over unchanged (weekly on Tuesday, one grouped pull request, a seven-day cooldown) and added a pip block for pyproject.toml on the same schedule", + "branch": "crab/tooling-tooling.dependabot-9de61c0c", + "recorded_at": "2026-09-25T17:51:14+00:00" + } + ] +} diff --git a/.crab/ledger.json b/.crab/ledger.json new file mode 100644 index 0000000..4387df9 --- /dev/null +++ b/.crab/ledger.json @@ -0,0 +1,361 @@ +{ + "schema": "hungry-crab.ledger/1", + "maw": "Devostasis", + "updated_at": "2026-09-25T17:51:54+00:00", + "meals": [ + { + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "url": "https://github.com/pypa/build", + "license": "MIT", + "mode": "COPY", + "date": "2026-09-25T17:47:29+00:00", + "candidates": 18, + "new": 18 + } + ], + "entries": [ + { + "id": "crab:architecture:architecture.pypa-build.raw", + "category": "architecture", + "key": "architecture.pypa-build.raw", + "title": "Architecture of pypa/build: hubs and layering (raw material)", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.08, + "serve_as": "idea", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:ci:ci.cache", + "category": "ci", + "key": "ci.cache", + "title": "Cache dependencies in CI", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.81, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:ci:ci.concurrency", + "category": "ci", + "key": "ci.concurrency", + "title": "Cancel superseded CI runs with concurrency groups", + "status": "served", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.63, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": "2026-09-25T17:51:54+00:00", + "reason": "", + "url": "https://github.com/drevendev/Devostasis/pull/37" + }, + { + "id": "crab:ci:ci.macos-runner", + "category": "ci", + "key": "ci.macos-runner", + "title": "Test on macOS runners", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.26, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:ci:ci.release-automation", + "category": "ci", + "key": "ci.release-automation", + "title": "Automate releases", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.37, + "serve_as": "issue", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:ci:ci.timeouts", + "category": "ci", + "key": "ci.timeouts", + "title": "Set job timeouts", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.54, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:ci:ci.windows-runner", + "category": "ci", + "key": "ci.windows-runner", + "title": "Test on Windows runners", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.44, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:deps:deps.python.others", + "category": "deps", + "key": "deps.python.others", + "title": "38 python dependencies the prey uses and you do not", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.1, + "serve_as": "idea", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:docs:docs.site", + "category": "docs", + "key": "docs.site", + "title": "Publish a documentation site", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.18, + "serve_as": "issue", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:history-lesson:history-lesson.pypa-build.fix-prone", + "category": "history-lesson", + "key": "history-lesson.pypa-build.fix-prone", + "title": "Lessons from pypa/build's history: fix-prone areas", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.25, + "serve_as": "issue", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:hygiene:hygiene.codeowners", + "category": "hygiene", + "key": "hygiene.codeowners", + "title": "Add CODEOWNERS", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.35, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:hygiene:hygiene.pr-template", + "category": "hygiene", + "key": "hygiene.pr-template", + "title": "Add a pull request template", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.35, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:hygiene:hygiene.readme-badges", + "category": "hygiene", + "key": "hygiene.readme-badges", + "title": "README: add status badges", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.21, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:tests:tests.coverage", + "category": "tests", + "key": "tests.coverage", + "title": "Measure test coverage", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.59, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:tooling:tooling.dependabot", + "category": "tooling", + "key": "tooling.dependabot", + "title": "Enable Dependabot updates", + "status": "served", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.68, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": "2026-09-25T17:51:21+00:00", + "reason": "", + "url": "https://github.com/drevendev/Devostasis/pull/36" + }, + { + "id": "crab:tooling:tooling.formatter.black", + "category": "tooling", + "key": "tooling.formatter.black", + "title": "Adopt the formatter black", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.56, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:tooling:tooling.linter.ruff", + "category": "tooling", + "key": "tooling.linter.ruff", + "title": "Adopt the linter ruff", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.56, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + }, + { + "id": "crab:tooling:tooling.pre-commit", + "category": "tooling", + "key": "tooling.pre-commit", + "title": "Add pre-commit hooks", + "status": "proposed", + "prey": "pypa/build", + "sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "score": 0.56, + "serve_as": "pr", + "first_seen": "2026-09-25T17:47:29+00:00", + "last_seen": "2026-09-25T17:47:29+00:00", + "last_prey": "pypa/build", + "last_sha": "58c921aada7f4e03edda65545d42db0c8b000bdc", + "sightings": 1, + "decided_at": null, + "reason": "", + "url": null + } + ] +} diff --git a/.devostasis/debt.json b/.devostasis/debt.json index 768ea04..44b86b3 100644 --- a/.devostasis/debt.json +++ b/.devostasis/debt.json @@ -2,13 +2,29 @@ "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", + "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", "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 875d388..b25e971 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", @@ -47,6 +47,16 @@ "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)", + "state": "open" + }, { "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/.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 new file mode 100644 index 0000000..03ced88 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,23 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + # Feature and maintenance PRs target the active release, per CONTRIBUTING.md. + target-branch: "release/0.3.0" + schedule: + interval: "weekly" + day: "tuesday" + groups: + github-actions: + patterns: + - "*" + cooldown: + default-days: 7 + - package-ecosystem: "pip" + directory: "/" + target-branch: "release/0.3.0" + schedule: + interval: "weekly" + day: "tuesday" + cooldown: + default-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e82e2ac..0b8baca 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 @@ -30,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 a65361a..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.1.9 +# 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.1.9 + default: v0.4.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..2889241 --- /dev/null +++ b/.github/workflows/work-observe.yml @@ -0,0 +1,136 @@ +name: Devostasis work observation + +on: + workflow_call: + inputs: + devostasis-ref: + type: string + default: v0.4.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 }} + 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 \ + --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 9609d56..34b3463 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,222 @@ 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: + +- 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`). + +- 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; 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 + 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: + +- 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 +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/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/README.md b/README.md index d20eb2b..edf44fa 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,39 @@ 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. +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, +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). + +**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 +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: @@ -46,8 +79,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 @@ -152,8 +187,16 @@ 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.4.0 release tag is published: + ```bash -pip install git+https://github.com/drevendev/devostasis@v0.1.9 +pip install git+https://github.com/drevendev/devostasis@v0.4.0 ``` Observe one repository (a GitHub token is read from `DEVOSTASIS_GITHUB_TOKEN`, @@ -212,7 +255,7 @@ permissions: jobs: vitals: - uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.1.9 + uses: drevendev/devostasis/.github/workflows/observe-self.yml@v0.4.0 decide: needs: vitals runs-on: ubuntu-latest @@ -265,13 +308,19 @@ 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; 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. + +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 9cfbd9d..7bb4c44 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,6 +4,166 @@ 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 + +**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 +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, +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. + +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 +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 +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. **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 + 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. **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 + 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. 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. +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 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 +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. + +## 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 @@ -13,7 +173,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 +205,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 +214,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; 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: @@ -67,7 +226,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 +242,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 +268,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 +304,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 +312,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 +437,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. 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, a second provider is implemented, and the calibration corpus is large enough @@ -377,15 +538,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/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..551f36c --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,16 @@ +# Third-party notices + +Attribution receipts are recorded in `.crab/attributions.json` for material +Hungry Crab carried into this repository. Both pending maintenance branches' +receipts are retained here. The upstream copyright and permission notice is +preserved in [LICENSES/pypa-build-MIT.txt](LICENSES/pypa-build-MIT.txt). + +## pypa/build @ 58c921a — MIT + +Source: +Obligation (copyright-notice): keep the source's copyright and permission notice with the material + +| Nutrient | Into | From | Mode | +|---|---|---|---| +| `crab:ci:ci.concurrency` | `.github/workflows/ci.yml` | `.github/workflows/test.yml` (adapted) | COPY | +| `crab:tooling:tooling.dependabot` | `.github/dependabot.yml` | `.github/dependabot.yml` (adapted) | COPY | diff --git a/docs/deployment.md b/docs/deployment.md index e76fd4d..e12c6a4 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,5 +1,10 @@ # Deployment +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. + 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.1.9 + 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 @@ -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.1.9" + 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 fbe8580..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 | @@ -18,6 +21,12 @@ 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 | +| `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 | @@ -26,7 +35,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 @@ -38,7 +47,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/README.md b/docs/spec/README.md index fc38fba..14fb296 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -18,6 +18,11 @@ 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 | +| [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 78ce32a..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. @@ -154,7 +165,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/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 22daf68..be77dbe 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`. @@ -57,9 +58,44 @@ 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) + +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 @@ -124,6 +160,145 @@ 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` | + +## 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 @@ -346,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 | @@ -357,11 +543,14 @@ 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, +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`, @@ -370,3 +559,37 @@ 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 work v1/v2; 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` | + +## 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/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/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..3873595 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,101 @@ 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, 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 + 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`). 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 +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). + +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 +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 +209,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 216b955..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": @@ -65,7 +70,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 | @@ -73,6 +78,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): @@ -83,8 +89,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 +100,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/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/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..4098cbb 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`, `HIST`). 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..b1912c5 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 | +| 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) | A rule version change never rewrites a @@ -167,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 @@ -320,6 +345,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 +383,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 +469,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/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-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/spec/work-scope.md b/docs/spec/work-scope.md new file mode 100644 index 0000000..b69d00a --- /dev/null +++ b/docs/spec/work-scope.md @@ -0,0 +1,170 @@ +# 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. +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. + +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. + +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 +`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..61a3b9f --- /dev/null +++ b/docs/work-scopes.md @@ -0,0 +1,161 @@ +# Evidence to Action in 0.4.0 + +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.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 +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`. + +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 +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/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 6cda0b8..a515f56 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", @@ -776,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", @@ -844,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", @@ -858,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/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..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": "45f84cc2652cb5d80e3b7edb689fa4b7973a5c5c76db2b5cfb2df06e8a2ec6c6", - "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,21 +28,23 @@ "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", "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", + "manifest_schema": "devostasis.manifest.v2", "observation_contract_version": "RAW-OBS-V0", - "observations_digest": "sha256:8be202ed47fbf0fc95288df179037b061e02192f6a48f6507b6cab5eaad0ce98", + "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:f5b315d95227724289b459582cc4352e8a64ca9ee7a5a90457c024377e7e3e23", - "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: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: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.1.9" - } - }, - "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 7827d02..f1cd102 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,17 +1669,84 @@ "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": { + "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", @@ -1557,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", @@ -1571,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 5a334f6..774439c 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: `868e4cb6c321a66e14bf6b1dc917cc51ba66ee9535edfb4d5e36ea9045631cd3` - 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 @@ -213,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:8be202ed47fbf0fc95288df179037b061e02192f6a48f6507b6cab5eaad0ce98` +- 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 3e5fbae..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:8be202ed47fbf0fc95288df179037b061e02192f6a48f6507b6cab5eaad0ce98", + "observations_digest": "sha256:89cec71d9b93d5ec2b691af8c7fe9aef590b566fce9edb9d284757bbcb6e10a4", "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/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/examples/work/bundle/evidence.json b/examples/work/bundle/evidence.json new file mode 100644 index 0000000..0b95fe8 --- /dev/null +++ b/examples/work/bundle/evidence.json @@ -0,0 +1 @@ +{"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 new file mode 100644 index 0000000..e2c3b2d --- /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"},"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 new file mode 100644 index 0000000..4cb09a5 --- /dev/null +++ b/examples/work/bundle/manifest.json @@ -0,0 +1 @@ +{"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 new file mode 100644 index 0000000..a740152 --- /dev/null +++ b/examples/work/bundle/policy.json @@ -0,0 +1 @@ +{"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/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..27f9fbe --- /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.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 new file mode 100644 index 0000000..0b95fe8 --- /dev/null +++ b/examples/work/evidence.json @@ -0,0 +1 @@ +{"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 new file mode 100644 index 0000000..29540a8 --- /dev/null +++ b/examples/work/github-observe.yml @@ -0,0 +1,22 @@ +# Copy into an adopter's .github/workflows directory after v0.4.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.4.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..397686f --- /dev/null +++ b/examples/work/gitlab-observe.yml @@ -0,0 +1,75 @@ +# 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.4.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 + # 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 "$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 + 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..e2c3b2d --- /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"},"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 new file mode 100644 index 0000000..a740152 --- /dev/null +++ b/examples/work/policy.json @@ -0,0 +1 @@ +{"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 17afd52..dd563a4 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.4.0" description = "Deterministic, model-free vital signs for software repositories." readme = "README.md" license = "MIT" 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/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 b7fa6d6..424b895 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")) @@ -143,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 new file mode 100644 index 0000000..7da355a --- /dev/null +++ b/scripts/make_work_examples.py @@ -0,0 +1,54 @@ +"""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"} +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": [], + "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 bc151f8..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.1.9" +__version__ = "0.4.0" diff --git a/src/devostasis/activity.py b/src/devostasis/activity.py index 70126a1..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 @@ -173,7 +178,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/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/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..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" @@ -67,6 +70,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" @@ -78,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", @@ -121,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 @@ -141,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",), + }, } @@ -151,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")) @@ -202,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") @@ -221,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, @@ -249,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, @@ -274,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, } @@ -307,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]: @@ -413,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, @@ -428,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) @@ -442,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")) @@ -452,6 +496,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 @@ -509,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: @@ -582,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 14eadea..d6fe010 100644 --- a/src/devostasis/cli.py +++ b/src/devostasis/cli.py @@ -17,8 +17,9 @@ 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 TOKEN_ENVS = ("DEVOSTASIS_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN") @@ -116,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: @@ -193,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})") @@ -224,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) @@ -241,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 @@ -499,6 +519,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 @@ -525,5 +547,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/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 08a161e..27b0115 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). @@ -24,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" @@ -43,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. @@ -65,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/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/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/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/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/revision_history.py b/src/devostasis/revision_history.py new file mode 100644 index 0000000..e7c8719 --- /dev/null +++ b/src/devostasis/revision_history.py @@ -0,0 +1,509 @@ +"""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" +ATTEMPT_STATES = {"VERIFY_PASS", "VERIFY_FAIL", "VERIFY_UNRESOLVED", "NON_VERIFY_TERMINAL", "NOT_EXECUTED", "UNKNOWN"} + +# 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") + 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") + 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 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]]: + """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 type(latest) is not int or latest < 1: + return False + 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]: + """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 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] + 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]] = [] + 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 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) + 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": [encode(record) for record in ordered]} + + +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, [] + 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: + """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, 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]] = {} + + 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") + 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_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 _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, + 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": _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): + 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 [] + 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) + + +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..ed39cc1 100644 --- a/src/devostasis/runner.py +++ b/src/devostasis/runner.py @@ -10,14 +10,14 @@ 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 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: @@ -52,13 +53,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 +98,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 +113,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 +138,53 @@ 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: + """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 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, + 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" + ) + 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 + + 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 @@ -147,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}" @@ -159,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: @@ -175,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, @@ -185,6 +258,7 @@ def run_project( path=str(path), requests=client.request_count, conditional_hits=client.conditional_hits, + execution_receipt=bundle.execution_receipt, ) @@ -253,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 @@ -269,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/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/src/devostasis/vitals/integrity.py b/src/devostasis/vitals/integrity.py index 6772a24..75c8c7b 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,12 +157,14 @@ 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: - """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 @@ -167,8 +183,43 @@ 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.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")] + + +def _unknown_current(revisions: list[dict[str, Any]], diagnostics: list[str], history: Reconciled) -> dict[str, Any]: + """Retain the accepted current-verdict diagnostic beside an independent admission/history gap.""" + active = [r for r in revisions if _record_consistent(r) and r.get("parents") + and isinstance(r.get("committed_at"), str)] + if not active: + return {} + latest = _newest(active) + current = latest.get("current_verdict") or VERDICT_UNKNOWN + if current not in VERDICTS: + diagnostics.append(f"CURRENT_VERDICT_UNRECOGNIZED:{current}") + current = VERDICT_UNKNOWN + if current != VERDICT_UNKNOWN: + return {} + diagnostics.append(f"CURRENT_VERDICT_UNKNOWN:{latest['revision']}") + return {"unknown_verdict_rule": UNKNOWN_RULE, "current_revision": { + "revision": latest["revision"], "committed_at": latest["committed_at"], + "current_verdict": current, "history_state": history.records.get(latest["revision"], {}).get("history_state", latest["history_state"])}} + + def evaluate(obs: ObservationSet) -> VitalResult: diagnostics: list[str] = [] + reconciled = reconcile(obs) + 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 +239,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, **_unknown_current(revisions, diagnostics, reconciled)}, 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 +286,37 @@ 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) + current_gap = _unknown_current(revisions, diagnostics, reconciled) + if current_gap: + current_gap["failure_ratio_14d"] = ratio(f, n) if n else None + strength = sample_strength(n) + if strength is not None: + current_gap["sample_strength"] = strength + if strength == SAMPLE_SPARSE: + diagnostics.append(SPARSE_DIAGNOSTIC) + return _result( + obs, None, EVAL_UNKNOWN, None, None, {**counts, **history, **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.", + ) + 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 +324,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 +354,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/src/devostasis/workscope/__init__.py b/src/devostasis/workscope/__init__.py new file mode 100644 index 0000000..a2cfa11 --- /dev/null +++ b/src/devostasis/workscope/__init__.py @@ -0,0 +1,5 @@ +"""Versioned, read-only Evidence to Action companion. No eighth Vital.""" + +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 new file mode 100644 index 0000000..4d826a3 --- /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, CONTRACTS, ENGINES +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": 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) + 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"] 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") + 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": decoded["manifest.json"]["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"] 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:] + 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..8339e57 --- /dev/null +++ b/src/devostasis/workscope/cli.py @@ -0,0 +1,206 @@ +"""Consumer commands; network commands never claim or execute tasks.""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import os +import shutil +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) + 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) + + +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, 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"): + 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 ("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": + 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") + 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, + 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) + 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", "handoff", "verify-handoff"): + 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", "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", "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", "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) + 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") + 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", "handoff"): + p.add_argument("--item", required=True) + 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 == "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 new file mode 100644 index 0000000..3f5bd89 --- /dev/null +++ b/src/devostasis/workscope/collect.py @@ -0,0 +1,420 @@ +"""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): + 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"])} + + +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) + 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 {**_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"), + "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 {**_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"), + "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, + source_ref=None, source_change=None, source_project=None, change_refs=None): + policy(config) + 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/{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) + 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} + 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") + 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() + 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"]] + 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["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, + "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 new file mode 100644 index 0000000..01edd2c --- /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, CONTRACTS +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"] 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"]: + 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/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 new file mode 100644 index 0000000..8eeddda --- /dev/null +++ b/src/devostasis/workscope/model.py @@ -0,0 +1,257 @@ +"""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, CONTRACTS + +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"), ("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 + 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") + 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")} + + +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"), ("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") + 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"] 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")) + 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: + 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) + 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"] 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) + 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..672f625 --- /dev/null +++ b/src/devostasis/workscope/planner.py @@ -0,0 +1,213 @@ +"""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 + 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) + + 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": 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}, + "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/conformance/test_integrity.py b/tests/conformance/test_integrity.py index 7d2157d..1604aa7 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,37 @@ 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 + + +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) 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/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()) 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..c36d96b 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 @@ -593,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_ci_history_admission.py b/tests/test_ci_history_admission.py new file mode 100644 index 0000000..6f8e8fb --- /dev/null +++ b/tests/test_ci_history_admission.py @@ -0,0 +1,129 @@ +"""Release-review regressions at real observation, evaluator and bundle boundaries.""" + +from copy import deepcopy +import json + +import pytest + +from devostasis.bundle import verify_dir +from devostasis.config import single_project +from devostasis.history import FilesystemHistoryStore +from devostasis.observations import ObservationSet +from devostasis.revision_history import ( + CARRIED, LINEAGE, REPLAYABLE_LINEAGE, parent_from_current, union_state, +) +from devostasis.runner import build_from_observations +from devostasis.vitals import integrity +from helpers import add +from test_revision_history import ( + LOCATOR, _build, _integrity, _obs, _passing, _record, _run, _states, +) + + +@pytest.mark.parametrize("conflicting,reverse", [(False, False), (True, False), (True, True)]) +def test_duplicate_current_revisions_never_improve_sample_or_overwrite_failure(tmp_path, conflicting, reverse): + rows = _passing([1, 2, 3]) + duplicate = deepcopy(rows[0]) if not conflicting else _record("p1", 1, [_run(801, [(1, "VERIFY_FAIL")])]) + rows.append(duplicate) + if reverse: + rows.reverse() + obs = ObservationSet.from_dict(_obs("2026-09-08T12:00:00Z", rows).to_dict()) + original = deepcopy(obs.to_dict()) + store = FilesystemHistoryStore(tmp_path) + project = single_project(LOCATOR, config_version="1") + bundle = build_from_observations(project, obs, store) + result = _integrity(bundle) + assert (result["band"], result["evaluation_status"]) == (None, "UNKNOWN") + assert "REVISION_RECORD_DUPLICATE:p1" in result["diagnostics"] + assert obs.to_dict() == original + assert verify_dir(store.commit(bundle)) == [] + saved = ObservationSet.from_dict(json.loads(bundle.members["observations.json"])) + assert build_from_observations(project, saved, FilesystemHistoryStore(tmp_path / "replay")).members == bundle.members + + +@pytest.mark.parametrize("bad", [None, [], {}, {"revision": "r"}, {"revision": "", "committed_at": "2026-09-04T10:00:00Z"}, + {"revision": 7, "committed_at": "2026-09-04T10:00:00Z"}, {"revision": "r", "committed_at": None}, + {"revision": "r", "committed_at": "invalid"}]) +def test_malformed_current_inventory_cannot_prove_carried_failure_left_branch(tmp_path, bad): + store = FilesystemHistoryStore(tmp_path) + _build(store, "2026-09-06T12:00:00Z", [_record("r", 4, [_run(41, [(1, "VERIFY_FAIL")])])]) + bundle, path = _build(store, "2026-09-07T12:00:00Z", _passing([1, 2, 3, 5]) + [bad]) + assert _integrity(bundle)["evaluation_status"] == "UNKNOWN" + assert _states(bundle)["r"] == "FAILURE_OBSERVED" + assert verify_dir(path) == [] + + +@pytest.mark.parametrize("bad_time", ["invalid", "2026-08-01T10:00:00Z", "2026-10-01T10:00:00Z"]) +def test_four_bundles_preserve_failure_after_rejected_immutable_timestamp(tmp_path, bad_time): + store = FilesystemHistoryStore(tmp_path) + passing = _passing([1, 2, 3, 5]) + failed = _record("r", 4, [_run(41, [(1, "VERIFY_FAIL")])]) + _build(store, "2026-09-06T12:00:00Z", passing + [failed]) + corrupted = _record("r", 4, [_run(41, [(2, "VERIFY_PASS")], latest=2)]) + corrupted["committed_at"] = bad_time + second, second_path = _build(store, "2026-09-07T12:00:00Z", passing + [corrupted]) + assert _states(second)["r"] == "FAILURE_OBSERVED" + assert _integrity(second)["evaluation_status"] == "UNKNOWN" + assert verify_dir(second_path) == [] + for day in (8, 9): + bundle, path = _build(store, f"2026-09-{day:02d}T12:00:00Z", passing + [_record("r", 4, [_run(41, [(2, "VERIFY_PASS")], latest=2)])]) + result = _integrity(bundle) + record = next(r for r in result["derived"]["revision_history"]["records"] if r["revision"] == "r") + assert record["committed_at"] == failed["committed_at"] + assert record["history_state"] == "FAILURE_OBSERVED" + assert result["derived"]["failed_count_14d"] == 1 and result["band"] == "FLAKY" + assert verify_dir(path) == [] + + +def test_equivalent_timestamp_and_proven_departure_remain_admissible(tmp_path): + store = FilesystemHistoryStore(tmp_path) + failed = _record("r", 4, [_run(41, [(1, "VERIFY_FAIL")])]) + _build(store, "2026-09-06T12:00:00Z", [failed]) + equivalent = deepcopy(failed); equivalent["committed_at"] = "2026-09-04T12:00:00+02:00" + bundle, _ = _build(store, "2026-09-07T12:00:00Z", [equivalent]) + assert _states(bundle)["r"] == "FAILURE_OBSERVED" + assert not any("IMMUTABLE_COMMIT_TIME_MISMATCH" in d for d in _integrity(bundle)["diagnostics"]) + partial, _ = _build(store, "2026-09-08T12:00:00Z", [], series_status="PARTIAL") + assert _states(partial)["r"] == "FAILURE_OBSERVED" + departed, _ = _build(store, "2026-09-09T12:00:00Z", []) + assert "r" not in _states(departed) + + +@pytest.mark.parametrize("attempts,latest", [([(1, "VERIFY_PASS"), (3, "VERIFY_PASS")], 3), + ([(1, "VERIFY_PASS"), (2, "VERIFY_PASS"), (3, "VERIFY_PASS")], 2), + ([(1, "VERIFY_PASS")], 10**12), ([(1, "UNKNOWN"), (2, "VERIFY_PASS")], 2)]) +def test_incomplete_or_unknown_attempts_never_prove_pass_only(attempts, latest): + parent = parent_from_current(_run(41, attempts, latest=latest), "test") + assert union_state([parent], []) == ("UNKNOWN_HISTORY", False) + + +@pytest.mark.parametrize("lineage", [LINEAGE, REPLAYABLE_LINEAGE]) +def test_unknown_attempts_stay_unknown_in_carried_lineages(lineage): + from devostasis.revision_history import encode, union_record + raw = _record("r", 4, [_run(41, [(1, "UNKNOWN"), (2, "VERIFY_PASS")])]) + records = [raw] if lineage == REPLAYABLE_LINEAGE else [encode(union_record("r", raw["committed_at"], [parent_from_current(raw["parents"][0], "test")], []))] + obs = _obs("2026-09-08T12:00:00Z", [_record("r", 4, [])]) + add(obs, CARRIED, {"lineage": lineage, "records": records}, "record") + assert integrity.evaluate(obs).band is None + assert integrity.evaluate(obs).derived["revision_history"]["records"][0]["history_state"] == "UNKNOWN_HISTORY" + + +def test_unknown_vocabulary_is_rejected_but_legitimate_transitions_and_failures_survive(): + from devostasis.revision_history import HistoryShapeError, merge_parents + with pytest.raises(HistoryShapeError): + parent_from_current(_run(41, [(1, "NOT_A_CI_STATE")]), "test") + pending = parent_from_current(_run(41, [(1, "VERIFY_UNRESOLVED")]), "test") + passed = parent_from_current(_run(41, [(1, "VERIFY_PASS")]), "test") + for first, second in ((pending, passed), (passed, pending)): + merged, conflicts = merge_parents([first], [second]) + assert union_state(merged, conflicts) == ("PASS_ONLY_OBSERVED", True) + failed = parent_from_current(_run(41, [(1, "VERIFY_FAIL"), (3, "UNKNOWN")], latest=3), "test") + assert union_state([failed], []) == ("FAILURE_OBSERVED", False) + + +def test_current_revision_metadata_matches_its_reconciled_unknown_history(): + from helpers import parent, revision + raw = revision("new", "2026-09-06T10:00:00Z", [parent("p-new", "UNKNOWN")], "UNKNOWN", "NO_DECISIVE_OBSERVED") + result = integrity.evaluate(_obs("2026-09-08T12:00:00Z", _passing([1, 2, 3]) + [raw])) + assert result.derived["current_revision"]["history_state"] == "UNKNOWN_HISTORY" + assert result.derived["unknown_history_count_14d"] == 1 diff --git a/tests/test_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_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_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_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_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_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_revision_history.py b/tests/test_revision_history.py new file mode 100644 index 0000000..ca112ce --- /dev/null +++ b/tests/test_revision_history.py @@ -0,0 +1,368 @@ +"""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 copy +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) + + +@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])) + 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) + + +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/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_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/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) 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..c52b3fe --- /dev/null +++ b/tests/test_workscope_adapters.py @@ -0,0 +1,305 @@ +"""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 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) + 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"]) + + +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 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." + } + ] +} 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..63805a7 --- /dev/null +++ b/tests/vectors/revision-history.json @@ -0,0 +1,3969 @@ +{ + "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", + "parent_groups": [ + { + "kind": "github_check_suite", + "latest_attempt": null, + "attempts": [ + { + "attempt": null, + "state": "VERIFY_FAIL" + } + ], + "parent_ids": [ + "github_checks:check_suite:41" + ] + } + ] + } + ], + "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", + "parent_groups": [ + { + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "parent_ids": [ + "github_actions:workflow_run:41" + ] + } + ] + } + ], + "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", + "parent_groups": [ + { + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_FAIL" + } + ], + "parent_ids": [ + "github_actions:workflow_run:41" + ] + } + ] + } + ], + "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", + "parent_groups": [ + { + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_FAIL" + } + ], + "parent_ids": [ + "github_actions:workflow_run:41" + ] + } + ] + } + ], + "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", + "parent_groups": [ + { + "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" + } + ], + "parent_ids": [ + "github_actions:workflow_run:41" + ] + } + ] + } + ], + "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", + "parent_groups": [ + { + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_FAIL" + } + ], + "parent_ids": [ + "github_actions:workflow_run:41" + ] + } + ] + } + ], + "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", + "parent_groups": [ + { + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_FAIL" + } + ], + "parent_ids": [ + "github_actions:workflow_run:41" + ] + } + ] + } + ], + "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", + "parent_groups": [ + { + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_PASS" + } + ], + "parent_ids": [ + "github_actions:workflow_run:901" + ] + } + ] + } + ], + "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", + "parent_groups": [ + { + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_FAIL" + } + ], + "parent_ids": [ + "github_actions:workflow_run:41" + ] + } + ] + } + ], + "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", + "parent_groups": [ + { + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_FAIL" + } + ], + "parent_ids": [ + "github_actions:workflow_run:40" + ] + } + ] + } + ], + "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", + "parent_groups": [ + { + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_FAIL" + } + ], + "parent_ids": [ + "github_actions:workflow_run:41" + ] + } + ] + } + ], + "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", + "parent_groups": [ + { + "kind": "github_actions_workflow_run", + "latest_attempt": 2, + "attempts": [ + { + "attempt": 2, + "state": "VERIFY_PASS" + }, + { + "attempt": 1, + "state": "VERIFY_FAIL" + } + ], + "parent_ids": [ + "github_actions:workflow_run:41" + ] + } + ] + } + ], + "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", + "parent_groups": [ + { + "kind": "github_check_suite", + "latest_attempt": null, + "attempts": [ + { + "attempt": null, + "state": "VERIFY_FAIL" + } + ], + "parent_ids": [ + "github_checks:check_suite:41" + ] + } + ] + } + ], + "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", + "parent_groups": [] + } + ], + "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", + "parent_groups": [] + } + ], + "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", + "parent_groups": [ + { + "kind": "github_actions_workflow_run", + "latest_attempt": 1, + "attempts": [ + { + "attempt": 1, + "state": "VERIFY_FAIL" + } + ], + "parent_ids": [ + "github_actions:workflow_run:41" + ] + } + ] + } + ], + "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", + "parent_groups": [ + { + "kind": "github_actions_workflow_run", + "latest_attempt": 3, + "attempts": [ + { + "attempt": 3, + "state": "VERIFY_PASS" + } + ], + "parent_ids": [ + "github_actions:workflow_run:41" + ] + } + ] + } + ], + "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", + "parent_groups": [ + { + "kind": "github_check_suite", + "latest_attempt": null, + "attempts": [ + { + "attempt": null, + "state": "VERIFY_UNRESOLVED" + } + ], + "parent_ids": [ + "github_checks:check_suite:41" + ] + } + ] + } + ], + "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", + "parent_groups": [], + "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." + } + ] +}