From ecded3121eaacc643323eed0f567dc25f428e806 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Wed, 16 Sep 2026 18:22:02 +0200 Subject: [PATCH 01/14] feat(lifecycle): feature model, State V2 and the shared projection (#162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0017 makes profiles (governance) and features (optional project-scope capabilities) orthogonal. This change adds the model and the read side; switching comes next in the lane. - data/features.json: forge-github / forge-gitlab — project scope, symmetric conflicts, work-forge flag, declared legacy_default (no hard-coded default). - manifest: Feature, Distribution.features / legacy_default_feature, Distribution.feature(); the catalogue refuses a machine-scope entry, an asymmetric conflict, a component claimed by two features, an undeclared component reference and an undeclared legacy default. - state: schema V2 with active_features. V1 still loads (no field); a V2 state naming a feature this release does not know refuses as INSTALL_STATE_CORRUPTED (upgrade the CLI, never downgrade the state), and the existing schema guard still refuses a newer schema. - features.project_install_state(): the single projection every read path will share; V1 projects to the declared legacy default; a V2 state activating two work forges refuses as STATE_CONFLICTING_WORK_FORGE_FEATURES. The projection never writes. - features.migrate_state(): stamps the current schema and seeds the legacy default exactly once, inside the existing install transaction (state-last); it plans no file change of its own, so a managed file that is already absent stays absent through the migration. - installer: a fresh state seeds the default feature (a fresh Standard install keeps shipping the GitHub templates, as it always has); a no-op install still saves the state when it was V1, which is what makes the migration persist. - gitlab-templates component and templates/gitlab/ (owned by forge-gitlab; the .gitlab/ equivalents of the GitHub templates). - New refusal codes: STATE_CONFLICTING_WORK_FORGE_FEATURES, FEATURE_UNKNOWN. - CI: tests.test_lifecycle_features runs in the lifecycle job. Verified: 295 lifecycle tests OK (21 new), machine/CLI suites OK, gates (conventions, scope, complexity, LOC) green. Refs #162 --- .github/workflows/ci.yml | 3 + ainative/lifecycle/data/components.json | 15 + ainative/lifecycle/data/features.json | 41 +++ ainative/lifecycle/errors.py | 4 + ainative/lifecycle/features.py | 126 ++++++++ ainative/lifecycle/installer.py | 35 ++- ainative/lifecycle/manifest.py | 112 ++++++- ainative/lifecycle/state.py | 17 +- templates/gitlab/issue_templates/bug.md | 21 ++ templates/gitlab/issue_templates/feature.md | 17 ++ .../gitlab/merge_request_templates/default.md | 13 + tests/test_lifecycle_features.py | 284 ++++++++++++++++++ tests/test_lifecycle_security.py | 7 + 13 files changed, 683 insertions(+), 12 deletions(-) create mode 100644 ainative/lifecycle/data/features.json create mode 100644 ainative/lifecycle/features.py create mode 100644 templates/gitlab/issue_templates/bug.md create mode 100644 templates/gitlab/issue_templates/feature.md create mode 100644 templates/gitlab/merge_request_templates/default.md create mode 100644 tests/test_lifecycle_features.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ea47d9f..8ed4c129 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,6 +201,9 @@ jobs: - name: Ownership, user modifications and external config run: python -m unittest tests.test_lifecycle_ownership -v + - name: Features, State V2 and the shared projection + run: python -m unittest tests.test_lifecycle_features -v + - name: Path traversal, link escape, tampered state, archive safety run: python -m unittest tests.test_lifecycle_security -v diff --git a/ainative/lifecycle/data/components.json b/ainative/lifecycle/data/components.json index 5b6485a9..83ae584a 100644 --- a/ainative/lifecycle/data/components.json +++ b/ainative/lifecycle/data/components.json @@ -147,6 +147,21 @@ "title": "GitHub contribution templates", "description": "Generic bug/feature issue templates and PR template, installed as individually-owned managed files." }, + "gitlab-templates": { + "version": 1, + "kind": "tree", + "source": "templates/gitlab", + "destination": ".gitlab", + "include": [ + "issue_templates/bug.md", + "issue_templates/feature.md", + "merge_request_templates/default.md" + ], + "ownership": "MANAGED_MUTABLE", + "required": false, + "title": "GitLab contribution templates", + "description": "GitLab issue and merge-request templates, installed as individually-owned managed files. Owned by the forge-gitlab feature (ADR-0017)." + }, "verified-workplane": { "version": 1, "kind": "marker", diff --git a/ainative/lifecycle/data/features.json b/ainative/lifecycle/data/features.json new file mode 100644 index 00000000..3ebb9688 --- /dev/null +++ b/ainative/lifecycle/data/features.json @@ -0,0 +1,41 @@ +{ + "_comment": [ + "Declarative features. A feature is an optional project-scope capability", + "that can be enabled or disabled independently of the governance profile", + "(ADR-0017). `components` are component IDs from components.json; a component", + "belongs to at most one feature. `conflicts` are feature IDs that may not be", + "active at the same time, and the relation must be declared symmetrically.", + "`legacy_default` is the feature every V1 project projects to: the", + "compatibility default, never a write (ADR-0017 section 4)." + ], + "schema_version": 1, + "legacy_default": "forge-github", + "features": { + "forge-github": { + "version": 1, + "scope": "project", + "work_forge": true, + "title": "GitHub work forge", + "summary": "GitHub issue and pull-request templates for the project.", + "components": [ + "github-templates" + ], + "conflicts": [ + "forge-gitlab" + ] + }, + "forge-gitlab": { + "version": 1, + "scope": "project", + "work_forge": true, + "title": "GitLab work forge", + "summary": "GitLab issue and merge-request templates for the project.", + "components": [ + "gitlab-templates" + ], + "conflicts": [ + "forge-github" + ] + } + } +} diff --git a/ainative/lifecycle/errors.py b/ainative/lifecycle/errors.py index 10809b85..a12c4ea0 100644 --- a/ainative/lifecycle/errors.py +++ b/ainative/lifecycle/errors.py @@ -40,6 +40,10 @@ "CLI_UPDATE_REQUIRED": EXIT_FAILED, "ROLLBACK_UNAVAILABLE": EXIT_FAILED, "APPLY_FAILED": EXIT_FAILED, + # Features (ADR-0017). A project has at most one work forge; two is a + # refusal, never a silent pick. + "STATE_CONFLICTING_WORK_FORGE_FEATURES": EXIT_FAILED, + "FEATURE_UNKNOWN": EXIT_INVALID_REQUEST, # Machine-wide integration (`ainative machine`, `ainative setup`). "MACHINE_MANIFEST_UNREADABLE": EXIT_INVALID_REQUEST, "MACHINE_VAULT_PAIR_REQUIRED": EXIT_INVALID_REQUEST, diff --git a/ainative/lifecycle/features.py b/ainative/lifecycle/features.py new file mode 100644 index 00000000..0d48e481 --- /dev/null +++ b/ainative/lifecycle/features.py @@ -0,0 +1,126 @@ +"""The feature view of a project: one projection, shared by every read path. + +ADR-0017 makes profiles and features orthogonal. A profile is a governance +level; a feature is an optional project-scope capability. State V2 records the +active features, older states do not — and "which features does this project +have?" must never have two answers. Every reader therefore goes through +`project_install_state()`, which is also the single place that knows the V1 +compatibility default (a legacy project is a GitHub project until it +explicitly switches). + +The projection never writes. `migrate_state()` is its write-side counterpart: +it stamps the current schema and seeds the legacy default exactly once, inside +an existing mutation transaction (state-last, ADR-0009 section 4). It plans no +file changes of its own, so a managed file that is already absent stays +absent through the migration; only an explicit feature transition seeds files. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from . import manifest as manifestlib +from . import state as statelib +from .errors import LifecycleError + + +@dataclass(frozen=True) +class EffectiveState: + """The feature view of one project, projected from whatever is stored.""" + + schema_version: int + active_profile: str + active_features: tuple[str, ...] + projected_from_legacy: bool + + def has_feature(self, name: str) -> bool: + return name in self.active_features + + def feature_objects(self, distribution: manifestlib.Distribution) -> tuple: + return tuple(distribution.feature(name) for name in self.active_features) + + +def project_install_state(state: statelib.InstallState | None, + distribution: manifestlib.Distribution) -> EffectiveState | None: + """The features a project effectively has; None when it has no state. + + A V1 state has no `active_features` and projects to the declared legacy + default (ADR-0017 section 4). A V2 state is validated: an undeclared + feature refuses as `INSTALL_STATE_CORRUPTED`, two work forges refuse as + `STATE_CONFLICTING_WORK_FORGE_FEATURES` — fail-closed, never a silent pick. + """ + + if state is None: + return None + if state.schema_version < statelib.SCHEMA_VERSION: + return EffectiveState(schema_version=state.schema_version, + active_profile=state.active_profile, + active_features=(legacy_default(distribution),), + projected_from_legacy=True) + return EffectiveState(schema_version=state.schema_version, + active_profile=state.active_profile, + active_features=_validated(state.active_features, distribution), + projected_from_legacy=False) + + +def legacy_default(distribution: manifestlib.Distribution) -> str: + """The feature a V1 project projects to; declared, never hard-coded.""" + + default = distribution.legacy_default_feature + if not default: + raise LifecycleError("MANIFEST_INVALID", + "the catalogue declares no legacy default feature") + return default + + +def _validated(names: list[str], distribution: manifestlib.Distribution) -> tuple[str, ...]: + ordered = tuple(dict.fromkeys(names)) + for name in ordered: + try: + distribution.feature(name) + except LifecycleError as error: + if error.code != "FEATURE_UNKNOWN": + raise + # A state naming a feature this release does not know is a state + # problem, not a request problem: the same stance as a newer schema + # — upgrade the CLI rather than downgrading state. + raise LifecycleError( + "INSTALL_STATE_CORRUPTED", + f"the saved state activates feature {name!r}, which this release " + "does not know; upgrade the CLI rather than downgrading state") from error + work_forges = [name for name in ordered if distribution.feature(name).work_forge] + if len(work_forges) > 1: + raise LifecycleError( + "STATE_CONFLICTING_WORK_FORGE_FEATURES", + "the saved state activates two work forges (" + + ", ".join(sorted(work_forges)) + + "); a project has at most one — use `ainative feature switch `", + features=sorted(work_forges)) + for name in ordered: + for other in distribution.feature(name).conflicts: + if other in ordered: + raise LifecycleError( + "INSTALL_STATE_CORRUPTED", + f"the saved state activates conflicting features " + f"{name!r} and {other!r}") + return ordered + + +def migrate_state(state: statelib.InstallState, + distribution: manifestlib.Distribution) -> statelib.InstallState: + """Stamp the current schema, seeding the legacy default exactly once. + + Returns the state object it was given (every caller works on the loaded + object); a state already at the current schema is returned unchanged, so + running this twice writes the same bytes. + """ + + if state.schema_version >= statelib.SCHEMA_VERSION: + return state + if not state.active_features: + state.active_features = [legacy_default(distribution)] + state.schema_version = statelib.SCHEMA_VERSION + return state + + +__all__ = ["EffectiveState", "project_install_state", "legacy_default", "migrate_state"] diff --git a/ainative/lifecycle/installer.py b/ainative/lifecycle/installer.py index 9589d0ae..7fa2c547 100644 --- a/ainative/lifecycle/installer.py +++ b/ainative/lifecycle/installer.py @@ -13,6 +13,7 @@ from pathlib import Path from . import environment +from . import features as featureslib from . import legacy as legacylib from . import manifest as manifestlib from . import planner as plannerlib @@ -93,9 +94,19 @@ def _blocking_transaction(project: Path) -> None: transactions=txnlib.summarise(pending)) -def _fresh_state(source: DistributionSource, profile: str) -> InstallState: +def _fresh_state(source: DistributionSource, profile: str, + distribution: Distribution) -> InstallState: + """A new project starts on the catalogue's legacy default feature. + + That default is the compatibility rule ADR-0017 section 4 fixes for V1 + projects; without it a fresh Standard install would stop shipping the + GitHub templates every install has shipped so far — a behavior change + nobody asked for. `feature switch none` opts out explicitly. + """ + return InstallState(stack_version=source.version, source_version=source.version, - active_profile=profile) + active_profile=profile, + active_features=[featureslib.legacy_default(distribution)]) def _commit_state(project: Path, state: InstallState, plan: Plan, @@ -141,9 +152,14 @@ def plan_profile(project: Path, distribution: Distribution, source: Distribution current = state if state is not None else statelib.load(project) adoption = legacylib.Adoption(False, (), (), (), ()) if current is None: - current = _fresh_state(source, target_profile) + current = _fresh_state(source, target_profile, distribution) adoption = legacylib.adopt(project, distribution, source, target_profile) current = legacylib.apply_to_state(current, adoption, stack_version=source.version) + else: + # A V1 state migrates inside this operation (state-last, ADR-0017 + # section 5). The migration itself plans no file change: a managed file + # that is already absent stays absent. + current = featureslib.migrate_state(current, distribution) plan = plannerlib.build_install_plan(project, distribution, source, current, target_profile, operation=operation) return plan, current, adoption @@ -169,11 +185,14 @@ def install(project: Path, target_profile: str, *, dry_run: bool = False, if dry_run or plan.is_noop: if plan.is_noop and not dry_run: - # Nothing had to change on disk, but the profile may still need - # recording. That is a write to the install state, so it takes the - # lock like every other write — an unlocked commit here could - # interleave with another operation's own commit. - if statelib.load(project) is None or state.active_profile != target_profile: + # Nothing had to change on disk, but the profile — or the schema a + # V1 state is still on — may need recording. That is a write to the + # install state, so it takes the lock like every other write — an + # unlocked commit here could interleave with another operation's + # own commit. + loaded = statelib.load(project) + if (loaded is None or state.active_profile != target_profile + or loaded.schema_version < statelib.SCHEMA_VERSION): with locklib.acquire(project, operation, force=force_unlock): state.active_profile = target_profile for identifier in distribution.effective_component_ids(target_profile): diff --git a/ainative/lifecycle/manifest.py b/ainative/lifecycle/manifest.py index 5632d098..61ed7e55 100644 --- a/ainative/lifecycle/manifest.py +++ b/ainative/lifecycle/manifest.py @@ -82,13 +82,35 @@ class Profile: components: tuple[str, ...] +# The only feature scope V1 knows: a feature is an optional project-scope +# capability. Machine-scope integration stays with `ainative machine` and is not +# a feature (ADR-0017 section 9). +FEATURE_SCOPE_PROJECT = "project" +FEATURE_SCOPES = (FEATURE_SCOPE_PROJECT,) + + +@dataclass(frozen=True) +class Feature: + """An optional capability, declared independently of any profile.""" + + name: str + scope: str + work_forge: bool + title: str + summary: str + components: tuple[str, ...] + conflicts: tuple[str, ...] + + @dataclass(frozen=True) class Distribution: - """The component and profile catalogue, already validated.""" + """The component, profile and feature catalogue, already validated.""" components: Mapping[str, Component] profiles: Mapping[str, Profile] default_profile: str + features: Mapping[str, Feature] = field(default_factory=dict) + legacy_default_feature: str = "" schema_version: int = 1 _cache: dict = field(default_factory=dict, compare=False, repr=False) @@ -100,6 +122,14 @@ def profile(self, name: str) -> Profile: raise LifecycleError("PROFILE_INVALID", f"unknown profile {name!r} (known: {known})") from None + def feature(self, name: str) -> Feature: + try: + return self.features[name] + except KeyError: + known = ", ".join(sorted(self.features)) + raise LifecycleError("FEATURE_UNKNOWN", + f"unknown feature {name!r} (known: {known})") from None + def component(self, identifier: str) -> Component: try: return self.components[identifier] @@ -222,6 +252,67 @@ def _build_profile(name: str, raw: Any) -> Profile: ) +def _build_feature(name: str, raw: Any, + components: Mapping[str, Component]) -> Feature: + if not isinstance(raw, dict): + raise LifecycleError("MANIFEST_INVALID", f"feature {name!r} is not an object") + scope = raw.get("scope") + if scope not in FEATURE_SCOPES: + raise LifecycleError( + "MANIFEST_INVALID", + f"feature {name!r}: scope {scope!r} is not a project feature; " + "machine-scope integration is not a feature (ADR-0017 section 9)") + identifiers = _strings(raw.get("components"), "components", name) + for identifier in identifiers: + if identifier not in components: + raise LifecycleError( + "MANIFEST_INVALID", + f"feature {name!r} references unknown component {identifier!r}") + return Feature( + name=name, + scope=scope, + work_forge=bool(raw.get("work_forge", False)), + title=str(raw.get("title", name)), + summary=str(raw.get("summary", "")), + components=identifiers, + conflicts=_strings(raw.get("conflicts"), "conflicts", name), + ) + + +def _link_features(features: Mapping[str, Feature]) -> None: + """Refuse a catalogue whose conflicts or component claims cannot be honored. + + Two features sharing a component would make disable/enable ambiguous (whose + file is it?); an asymmetric conflict would let one side activate what the + other refuses; both are catalogue defects, not states a later stage could + resolve. + """ + + claimed: dict[str, str] = {} + for name, feature in features.items(): + if name in feature.conflicts: + raise LifecycleError("MANIFEST_INVALID", + f"feature {name!r} conflicts with itself") + for other in feature.conflicts: + if other not in features: + raise LifecycleError( + "MANIFEST_INVALID", + f"feature {name!r} conflicts with unknown feature {other!r}") + if name not in features[other].conflicts: + raise LifecycleError( + "MANIFEST_INVALID", + f"the conflict between {name!r} and {other!r} must be " + "declared symmetrically") + for identifier in feature.components: + if identifier in claimed: + raise LifecycleError( + "MANIFEST_INVALID", + f"component {identifier!r} is claimed by features " + f"{claimed[identifier]!r} and {name!r}; a component belongs " + "to exactly one feature") + claimed[identifier] = name + + def _reject_collisions(components: Mapping[str, Component]) -> None: """Two destinations that differ only in case are one path on Windows. @@ -266,7 +357,21 @@ def load(data_dir: Path | None = None) -> Distribution: if default not in profiles: raise LifecycleError("MANIFEST_INVALID", f"default profile {default!r} is not declared") - distribution = Distribution(components=components, profiles=profiles, default_profile=default, + feature_payload = _read_json(directory / "features.json") + raw_features = feature_payload.get("features") + if not isinstance(raw_features, dict) or not raw_features: + raise LifecycleError("MANIFEST_INVALID", "features.json declares no features") + features = {name: _build_feature(name, raw, components) + for name, raw in sorted(raw_features.items())} + _link_features(features) + legacy = feature_payload.get("legacy_default") + if not isinstance(legacy, str) or legacy not in features: + raise LifecycleError("MANIFEST_INVALID", + f"legacy_default {legacy!r} is not a declared feature") + + distribution = Distribution(components=components, profiles=profiles, + default_profile=default, features=features, + legacy_default_feature=legacy, schema_version=int(profile_payload.get("schema_version", 1))) for name in profiles: distribution.effective_component_ids(name) # proves the graph resolves @@ -274,8 +379,9 @@ def load(data_dir: Path | None = None) -> Distribution: __all__ = [ - "Component", "Profile", "Distribution", "load", "DATA_DIR", + "Component", "Profile", "Feature", "Distribution", "load", "DATA_DIR", "KIND_TREE", "KIND_FILE", "KIND_TEMPLATE", "KIND_EXTERNAL_BLOCK", "KIND_JSON_HOOK", "KIND_MARKER", "KIND_DATA_ROOT", "KINDS", "MANAGED_IMMUTABLE", "MANAGED_MUTABLE", "USER_DATA", "EXTERNAL_CONFIG", "OWNERSHIPS", + "FEATURE_SCOPE_PROJECT", "FEATURE_SCOPES", ] diff --git a/ainative/lifecycle/state.py b/ainative/lifecycle/state.py index c680e1c3..573ae310 100644 --- a/ainative/lifecycle/state.py +++ b/ainative/lifecycle/state.py @@ -25,7 +25,7 @@ from .errors import LifecycleError -SCHEMA_VERSION = 1 +SCHEMA_VERSION = 2 LIFECYCLE_DIRNAME = Path(".ai-native") / "lifecycle" STATE_RELATIVE = LIFECYCLE_DIRNAME / "state.json" @@ -152,6 +152,11 @@ class InstallState: source_version: str = "0.0.0" source_revision: str | None = None installed_components: list[str] = field(default_factory=list) + # V2 (ADR-0017): the optional project-scope features this project activates. + # Empty on a V1 state (the schema that predates the field) and migrated + # explicitly; never derived here, because the catalogue of features is not + # this module's to know. + active_features: list[str] = field(default_factory=list) managed_files: list[ManagedFile] = field(default_factory=list) last_transaction: str | None = None update_channel: str = "stable" @@ -189,6 +194,7 @@ def to_record(self) -> dict[str, Any]: sorted(self.managed_files, key=lambda item: (item.component, item.path))] record["installed_components"] = sorted(set(self.installed_components)) + record["active_features"] = sorted(set(self.active_features)) return record @classmethod @@ -210,6 +216,14 @@ def from_record(cls, raw: Any) -> "InstallState": if not isinstance(components, list) or not all(isinstance(i, str) for i in components): raise LifecycleError("INSTALL_STATE_CORRUPTED", "installed_components must be a list of strings") + # V1 (the schema before features) has no `active_features` and must keep + # loading; anything that is present but not a list of strings is corrupt, + # not "an old state": guessing is how a state naming nothing becomes a + # state pretending to name something. + features = raw.get("active_features", []) if version >= 2 else [] + if not isinstance(features, list) or not all(isinstance(item, str) for item in features): + raise LifecycleError("INSTALL_STATE_CORRUPTED", + "active_features must be a list of strings") files = raw.get("managed_files", []) if not isinstance(files, list): raise LifecycleError("INSTALL_STATE_CORRUPTED", "managed_files must be a list") @@ -227,6 +241,7 @@ def from_record(cls, raw: Any) -> "InstallState": source_version=str(raw.get("source_version", "0.0.0")), source_revision=raw.get("source_revision"), installed_components=list(components), + active_features=list(features), managed_files=[ManagedFile.from_record(item) for item in files], last_transaction=raw.get("last_transaction"), update_channel=str(raw.get("update_channel", "stable")), diff --git a/templates/gitlab/issue_templates/bug.md b/templates/gitlab/issue_templates/bug.md new file mode 100644 index 00000000..9295ac27 --- /dev/null +++ b/templates/gitlab/issue_templates/bug.md @@ -0,0 +1,21 @@ +## Problem + + + +## Reproduction + + + +## Expected outcome + + + +## Acceptance criteria + + + +- [ ] ... + +## Affected surface + + diff --git a/templates/gitlab/issue_templates/feature.md b/templates/gitlab/issue_templates/feature.md new file mode 100644 index 00000000..eda86a11 --- /dev/null +++ b/templates/gitlab/issue_templates/feature.md @@ -0,0 +1,17 @@ +## Problem + + + +## Expected outcome + + + +## Acceptance criteria + + + +- [ ] ... + +## Out of scope + + diff --git a/templates/gitlab/merge_request_templates/default.md b/templates/gitlab/merge_request_templates/default.md new file mode 100644 index 00000000..f252570b --- /dev/null +++ b/templates/gitlab/merge_request_templates/default.md @@ -0,0 +1,13 @@ +## What changed + +## Why + +Refs # + +## Validation + +- [ ] Relevant validation completed +- [ ] Documentation updated if required +- [ ] No unrelated scope included + +## Risk diff --git a/tests/test_lifecycle_features.py b/tests/test_lifecycle_features.py new file mode 100644 index 00000000..19076815 --- /dev/null +++ b/tests/test_lifecycle_features.py @@ -0,0 +1,284 @@ +"""The feature model, State V2, and the one projection every reader shares. + +ADR-0017 makes profiles (governance) and features (optional project-scope +capabilities) orthogonal. A V1 state predates `active_features`; it projects to +the declared legacy default — a GitHub project until it explicitly switches — +and migrates inside a normal mutation, state-last. These tests pin the model, +the projection and the migration, including every refusal that must never be a +silent pick: two work forges, an undeclared feature, a malformed catalogue. +""" + +from __future__ import annotations + +import json +import subprocess +import unittest +from pathlib import Path + +from tests.lifecycle_support import LifecycleTestCase, write_text +from ainative.lifecycle import features as featureslib +from ainative.lifecycle import manifest as manifestlib +from ainative.lifecycle import state as statelib +from ainative.lifecycle.errors import LifecycleError + +LEGACY = "forge-github" +GITLAB = "forge-gitlab" + + +def write_catalogue(directory: Path, *, features: dict, legacy: str = "forge") -> Path: + """A minimal catalogue: one component, one profile, the given features.""" + + directory.mkdir(parents=True, exist_ok=True) + write_text(directory / "components.json", json.dumps({ + "schema_version": 1, + "components": {"tpl": {"kind": "file", "ownership": "MANAGED_MUTABLE", + "source": "AGENTS.md", "destination": "TEMPLATE.md"}}})) + write_text(directory / "profiles.json", json.dumps({ + "schema_version": 1, "default": "standard", + "profiles": {"standard": {"extends": None, "components": []}}})) + write_text(directory / "features.json", json.dumps({ + "schema_version": 1, "legacy_default": legacy, "features": features})) + return directory + + +class FeatureCatalogue(unittest.TestCase): + + def setUp(self) -> None: + import shutil + import tempfile + + self.root = Path(tempfile.mkdtemp(prefix="ainative-features-")) + self.addCleanup(shutil.rmtree, self.root, ignore_errors=True) + self.distribution = manifestlib.load() + + def feature(self, name: str, **fields) -> dict: + base = {"scope": "project", "work_forge": False, "components": [], "conflicts": []} + base.update(fields) + return base + + def test_the_real_catalogue_declares_both_work_forges_and_a_default(self): + github = self.distribution.feature(LEGACY) + gitlab = self.distribution.feature(GITLAB) + self.assertEqual(self.distribution.legacy_default_feature, LEGACY) + for feature in (github, gitlab): + self.assertEqual(feature.scope, manifestlib.FEATURE_SCOPE_PROJECT) + self.assertTrue(feature.work_forge) + self.assertIn(GITLAB, github.conflicts) + self.assertIn(LEGACY, gitlab.conflicts) + self.assertEqual(github.components, ("github-templates",)) + self.assertEqual(gitlab.components, ("gitlab-templates",)) + + def test_an_undeclared_feature_id_is_refused(self): + with self.assertRaises(LifecycleError) as raised: + self.distribution.feature("forge-sourceforge") + self.assertEqual(raised.exception.code, "FEATURE_UNKNOWN") + + def test_a_machine_scope_entry_is_not_a_feature(self): + directory = write_catalogue( + self.root / "machine", + features={"hal": self.feature("hal", scope="machine")}) + with self.assertRaises(LifecycleError) as raised: + manifestlib.load(directory) + self.assertEqual(raised.exception.code, "MANIFEST_INVALID") + + def test_asymmetric_conflicts_are_refused(self): + directory = write_catalogue( + self.root / "asymmetric", + features={"a": self.feature("a", components=["tpl"], conflicts=["b"]), + "b": self.feature("b", components=[])}) + with self.assertRaises(LifecycleError) as raised: + manifestlib.load(directory) + self.assertEqual(raised.exception.code, "MANIFEST_INVALID") + + def test_a_component_claimed_by_two_features_is_refused(self): + directory = write_catalogue( + self.root / "claimed", + features={"a": self.feature("a", components=["tpl"]), + "b": self.feature("b", components=["tpl"])}) + with self.assertRaises(LifecycleError) as raised: + manifestlib.load(directory) + self.assertEqual(raised.exception.code, "MANIFEST_INVALID") + + def test_a_feature_referencing_an_unknown_component_is_refused(self): + directory = write_catalogue( + self.root / "unknown-component", + features={"a": self.feature("a", components=["ghost"])}) + with self.assertRaises(LifecycleError) as raised: + manifestlib.load(directory) + self.assertEqual(raised.exception.code, "MANIFEST_INVALID") + + def test_the_legacy_default_must_be_declared(self): + directory = write_catalogue( + self.root / "legacy", legacy="ghost", + features={"a": self.feature("a")}) + with self.assertRaises(LifecycleError) as raised: + manifestlib.load(directory) + self.assertEqual(raised.exception.code, "MANIFEST_INVALID") + + +class StateProjection(LifecycleTestCase): + """One projection, one answer — whatever schema is on disk.""" + + def setUp(self) -> None: + super().setUp() + self.install("standard") + + def v1_record(self) -> dict: + """Rewrite the state as the release before V2 wrote it.""" + + path = statelib.state_path(self.project) + record = json.loads(path.read_text(encoding="utf-8")) + record["schema_version"] = 1 + record.pop("active_features", None) + statelib.write_atomic(path, json.dumps(record, indent=2, sort_keys=True) + "\n") + return record + + def effective(self): + return featureslib.project_install_state( + statelib.load(self.project), manifestlib.load()) + + def test_a_v1_project_projects_to_the_legacy_default(self): + self.v1_record() + effective = self.effective() + self.assertEqual(effective.schema_version, 1) + self.assertEqual(effective.active_features, (LEGACY,)) + self.assertTrue(effective.projected_from_legacy) + self.assertTrue(effective.has_feature(LEGACY)) + + def test_a_v1_verified_project_projects_to_the_legacy_default(self): + self.switch("verified") + self.v1_record() + self.assertEqual(self.effective().active_features, (LEGACY,)) + + def test_a_v1_project_with_a_gitlab_remote_still_projects_to_the_legacy_default(self): + subprocess.run(["git", "-C", str(self.project), "remote", "add", "origin", + "https://gitlab.com/org/project.git"], check=True, + capture_output=True) + self.v1_record() + self.assertEqual(self.effective().active_features, (LEGACY,)) + + def test_a_v1_project_with_no_remote_still_projects_to_the_legacy_default(self): + remotes = subprocess.run(["git", "-C", str(self.project), "remote"], + check=True, capture_output=True, text=True) + self.assertEqual(remotes.stdout.strip(), "") + self.v1_record() + self.assertEqual(self.effective().active_features, (LEGACY,)) + + def test_the_projection_is_read_only(self): + from ainative.lifecycle.digest import digest_file + + self.v1_record() + path = statelib.state_path(self.project) + before = digest_file(path) + self.effective() + self.effective() + self.assertEqual(digest_file(path), before) + + def test_a_v2_state_with_one_forge_projects_it(self): + state = statelib.load(self.project) + state.active_features = [GITLAB] + statelib.save(self.project, state) + self.assertEqual(self.effective().active_features, (GITLAB,)) + self.assertFalse(self.effective().projected_from_legacy) + + def test_a_v2_state_with_no_forge_is_valid(self): + state = statelib.load(self.project) + state.active_features = [] + statelib.save(self.project, state) + self.assertEqual(self.effective().active_features, ()) + + def test_a_v2_state_with_two_work_forges_is_refused(self): + state = statelib.load(self.project) + state.active_features = [LEGACY, GITLAB] + statelib.save(self.project, state) + from ainative.lifecycle.digest import digest_file + + before = digest_file(statelib.state_path(self.project)) + with self.assertRaises(LifecycleError) as raised: + self.effective() + self.assertEqual(raised.exception.code, "STATE_CONFLICTING_WORK_FORGE_FEATURES") + self.assertEqual(digest_file(statelib.state_path(self.project)), before, + "a refused projection rewrote the state") + + def test_a_v2_state_naming_an_undeclared_feature_is_refused(self): + state = statelib.load(self.project) + state.active_features = ["forge-sourceforge"] + statelib.save(self.project, state) + with self.assertRaises(LifecycleError) as raised: + self.effective() + self.assertEqual(raised.exception.code, "INSTALL_STATE_CORRUPTED") + + def test_the_projection_is_idempotent(self): + self.v1_record() + first = self.effective() + second = self.effective() + self.assertEqual(first, second) + state = statelib.load(self.project) + statelib.save(self.project, state) + self.assertEqual(self.effective(), second) + + +class StateMigration(LifecycleTestCase): + + def snapshot(self, *, skip_state: bool = True) -> dict: + from ainative.lifecycle.digest import digest_file + + state_path = statelib.state_path(self.project) + return {path.relative_to(self.project).as_posix(): digest_file(path) or "" + for path in self.project.rglob("*") + if path.is_file() and not (skip_state and path == state_path)} + + def v1_record(self) -> dict: + path = statelib.state_path(self.project) + record = json.loads(path.read_text(encoding="utf-8")) + record["schema_version"] = 1 + record.pop("active_features", None) + statelib.write_atomic(path, json.dumps(record, indent=2, sort_keys=True) + "\n") + return record + + def test_a_no_op_install_migrates_a_v1_state(self): + self.install("standard") + self.v1_record() + before = self.snapshot() + self.install("standard") + state = statelib.load(self.project) + self.assertEqual(state.schema_version, statelib.SCHEMA_VERSION) + self.assertEqual(state.active_features, [LEGACY]) + self.assertEqual(self.snapshot(), before, + "the migration changed managed files") + + def test_migration_is_idempotent(self): + from ainative.lifecycle.digest import digest_file + + self.install("standard") + self.v1_record() + self.install("standard") + path = statelib.state_path(self.project) + migrated = digest_file(path) + self.install("standard") + self.assertEqual(digest_file(path), migrated, + "a second install rewrote the migrated state") + self.assertEqual(statelib.load(self.project).active_features, [LEGACY]) + + def test_migrate_state_leaves_a_current_state_untouched(self): + state = statelib.InstallState(active_features=[GITLAB]) + migrated = featureslib.migrate_state(state, manifestlib.load()) + self.assertIs(migrated, state) + self.assertEqual(migrated.active_features, [GITLAB]) + + def test_the_effective_feature_set_is_identical_across_the_migration(self): + """Read-only parity: projection before == projection after (ADR-0017 §4).""" + + self.install("standard") + self.v1_record() + before = featureslib.project_install_state(statelib.load(self.project), + manifestlib.load()) + self.install("standard") + after = featureslib.project_install_state(statelib.load(self.project), + manifestlib.load()) + self.assertEqual(before.active_features, after.active_features) + self.assertEqual(before.active_profile, after.active_profile) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_lifecycle_security.py b/tests/test_lifecycle_security.py index ad1424c4..5dd32b5e 100644 --- a/tests/test_lifecycle_security.py +++ b/tests/test_lifecycle_security.py @@ -212,6 +212,13 @@ def _write(self, directory: Path, components: dict, profiles: dict) -> Path: (directory / "profiles.json").write_text( json.dumps({"schema_version": 1, "default": "standard", "profiles": profiles}), encoding="utf-8") + # The feature catalogue is a third required manifest (ADR-0017); these + # fixtures are about components and profiles, so it is minimal. + (directory / "features.json").write_text( + json.dumps({"schema_version": 1, "legacy_default": "forge", + "features": {"forge": {"scope": "project", "work_forge": True, + "components": [], "conflicts": []}}}), + encoding="utf-8") return directory def setUp(self) -> None: From bbfbd3f615ce4dc9414a5d4af53cbafd05042b48 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Wed, 16 Sep 2026 18:34:25 +0200 Subject: [PATCH 02/14] feat(lifecycle): feature switching in one transaction (#162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ainative feature enable | disable | switch | status` moves a project between optional capabilities with the profile path's guarantees unchanged: project lifecycle lock, reload, project the effective V2 set, plan, apply, verify, state last, commit. A V1 state migrates inside the same transaction. - profiles.json: `github-templates` leaves the standard profile; the feature `forge-github` owns it now (a component belongs to exactly one owner), so `feature switch none` really is Generic Git. - planner: the wanted set is profile components plus the active features' components, computed by one shared function the installer also records from (plan and record cannot drift). Feature files are seeded when a feature is enabled; ordinary maintenance never re-seeds an absent feature file — a file the user removed stays removed until an explicit transition (ADR-0017 §5). - installer.set_features(): the transition semantics. `enable` refuses a conflicting active feature with the remedy (`FEATURE_CONFLICT`, never an implicit disable); `switch` replaces the work forge in one plan; `switch none` removes every work forge and nothing else. A no-op transition changes nothing — including the state bytes — unless it still has something to record (migration, moved set, dropped component records). - feature_cli.py: the command surface, split out of cli.py so the dispatcher stays under its LOC budget; status derives from the shared projection. - New refusal code FEATURE_CONFLICT. - Tests: switching, disable preserves a modified file, absent feature file stays absent through installs and is re-seeded by an explicit transition, the migration fixture (a V1 state whose GitHub template is absent stays absent), a round trip without user-data loss, CLI status/switch. - Docs: DISTRIBUTION-LIFECYCLE §18 and CHANGELOG. Verified: 307 lifecycle tests OK (33 in the features suite), CLI/machine suites OK, gates green (conventions, scope, complexity, LOC — cli.py back under 800), five non-vacuity guards re-proved. Refs #162 --- CHANGELOG.md | 8 ++ ainative/cli.py | 11 ++ ainative/lifecycle/data/profiles.json | 3 +- ainative/lifecycle/errors.py | 3 + ainative/lifecycle/feature_cli.py | 97 +++++++++++++++++ ainative/lifecycle/features.py | 45 +++++++- ainative/lifecycle/installer.py | 105 +++++++++++++++++-- ainative/lifecycle/planner.py | 64 ++++++++++-- docs/DISTRIBUTION-LIFECYCLE.md | 33 ++++++ tests/test_lifecycle_features.py | 144 ++++++++++++++++++++++++++ 10 files changed, 493 insertions(+), 20 deletions(-) create mode 100644 ainative/lifecycle/feature_cli.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 187d3f26..0ef79b32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,14 @@ project adheres to [Semantic Versioning](https://semver.org/). workflow verifies a declared `V3_BRIDGE_RELEASE` through the V2 selection rules before publishing a V3 release; a missing, unpublished or bundle-less bridge blocks the release. +- **Features: profiles and capabilities are independent** (#162, ADR-0017): + `ainative feature enable | disable | switch | status` manages optional + project-scope capabilities in one lifecycle transaction. `forge-github` is + the default; it conflicts with `forge-gitlab`; a project has at most one + work forge, and `feature switch none` is the Generic Git shape. State schema + V2 records `active_features`; a V1 state projects to the compatibility + default and migrates inside the next mutation without resurrecting an + absent managed file. ### Security diff --git a/ainative/cli.py b/ainative/cli.py index 8e77bd1a..e55460b4 100644 --- a/ainative/cli.py +++ b/ainative/cli.py @@ -176,8 +176,10 @@ def build_parser() -> argparse.ArgumentParser: help="never prompt; choices come from flags only") from ainative.knowledge.cli import add_context_parser, add_knowledge_parser + from ainative.lifecycle.feature_cli import add_feature_parser add_knowledge_parser(commands) add_context_parser(commands) + add_feature_parser(commands) for name in VERIFIED_COMMANDS: commands.add_parser(name, add_help=False, @@ -306,6 +308,14 @@ def _cmd_profile(args: argparse.Namespace) -> int: return _report(args, result.to_record(), _uninstall_text(result)) +def _cmd_feature(args: argparse.Namespace) -> int: + from .lifecycle.feature_cli import run_feature_command + + return run_feature_command(args, project=_project(args), + report=lambda record, text: _report(args, record, text), + plan_text=_plan_text) + + def _confirm_purge(args: argparse.Namespace, project: Path) -> bool: """Interactive confirmation. Without a TTY this is always False, so `--yes` is the only way through — no CI ever blocks on a prompt.""" @@ -700,6 +710,7 @@ def _cmd_setup(args: argparse.Namespace) -> int: "knowledge": _cmd_knowledge, "context": _cmd_context, "profile": _cmd_profile, + "feature": _cmd_feature, "status": _cmd_status, "doctor": _cmd_doctor, "repair": _cmd_repair, diff --git a/ainative/lifecycle/data/profiles.json b/ainative/lifecycle/data/profiles.json index a62225e3..c48c6f51 100644 --- a/ainative/lifecycle/data/profiles.json +++ b/ainative/lifecycle/data/profiles.json @@ -21,8 +21,7 @@ "skills-agents", "machine-config", "gitignore-entry", - "claude-hook", - "github-templates" + "claude-hook" ] }, "verified": { diff --git a/ainative/lifecycle/errors.py b/ainative/lifecycle/errors.py index a12c4ea0..5e7e1653 100644 --- a/ainative/lifecycle/errors.py +++ b/ainative/lifecycle/errors.py @@ -44,6 +44,9 @@ # refusal, never a silent pick. "STATE_CONFLICTING_WORK_FORGE_FEATURES": EXIT_FAILED, "FEATURE_UNKNOWN": EXIT_INVALID_REQUEST, + # Enabling a feature that conflicts with an active one: the remedy is an + # explicit switch, not an implicit disable. + "FEATURE_CONFLICT": EXIT_INVALID_REQUEST, # Machine-wide integration (`ainative machine`, `ainative setup`). "MACHINE_MANIFEST_UNREADABLE": EXIT_INVALID_REQUEST, "MACHINE_VAULT_PAIR_REQUIRED": EXIT_INVALID_REQUEST, diff --git a/ainative/lifecycle/feature_cli.py b/ainative/lifecycle/feature_cli.py new file mode 100644 index 00000000..5b7c19f9 --- /dev/null +++ b/ainative/lifecycle/feature_cli.py @@ -0,0 +1,97 @@ +"""The `ainative feature` command surface: parser, rendering, transitions. + +Split out of `cli.py` for the size budget. The command is self-contained — its +grammar, its status rendering and its transition handler — and the dispatcher +stays the dispatcher. The two rendering callables are injected so this module +never imports `cli` back (which would be a cycle). +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Callable + +from . import features as featureslib +from . import installer +from . import manifest as manifestlib +from . import state as statelib + + +def add_feature_parser(commands) -> None: + feature = commands.add_parser("feature", + help="Inspect or change optional project features.") + feature_commands = feature.add_subparsers(dest="feature_command", required=True) + status = feature_commands.add_parser("status", + help="Report the effective feature set.") + _common(status, dry_run=False) + enable = feature_commands.add_parser("enable", help="Activate a feature.") + enable.add_argument("target") + _common(enable) + disable = feature_commands.add_parser("disable", help="Deactivate a feature.") + disable.add_argument("target") + _common(disable) + switch = feature_commands.add_parser( + "switch", help="Replace the active work forge ('none' for Generic Git).") + switch.add_argument("target", help="a feature name, or 'none'") + _common(switch) + + +def _common(parser: argparse.ArgumentParser, *, dry_run: bool = True) -> None: + parser.add_argument("--project", type=Path, default=None, + help="project root (default: the current directory)") + parser.add_argument("--json", action="store_true", help="machine-readable output") + if dry_run: + parser.add_argument("--dry-run", action="store_true", + help="print the change plan; touch nothing") + parser.add_argument("--force-unlock", action="store_true", + help="take the lifecycle lock even if another one is recorded") + + +def status_record(project: Path) -> tuple[dict, str]: + """The status payload and its text rendering, from the shared projection.""" + + distribution = manifestlib.load() + effective = featureslib.project_install_state(statelib.load(project), distribution) + active = list(effective.active_features) if effective else [] + record = { + "installed": effective is not None, + "active_profile": effective.active_profile if effective else None, + "active_features": active, + "projected_from_legacy": bool(effective and effective.projected_from_legacy), + "declared": {name: {"title": value.title, "work_forge": value.work_forge, + "components": list(value.components), + "conflicts": list(value.conflicts)} + for name, value in sorted(distribution.features.items())}, + } + lines = [f"Active features: {', '.join(active) or 'none'}"] + for name in sorted(record["declared"]): + mark = "*" if name in active else " " + lines.append(f" [{mark}] {name} — {record['declared'][name]['title']}") + if record["projected_from_legacy"]: + lines.append(" (projected from the V1 state: " + f"{featureslib.legacy_default(distribution)} is the " + "compatibility default)") + return record, "\n".join(lines) + + +def run_feature_command(args: argparse.Namespace, *, project: Path, + report: Callable[[dict, str], int], + plan_text: Callable[[object], str]) -> int: + if args.feature_command == "status": + record, text = status_record(project) + return report(record, text) + + transition = {"enable": None, "disable": None, "switch_to": None} + if args.feature_command == "enable": + transition["enable"] = args.target + elif args.feature_command == "disable": + transition["disable"] = args.target + else: + transition["switch_to"] = args.target + result = installer.set_features(project, dry_run=args.dry_run, + force_unlock=args.force_unlock, **transition) + return report(result.to_record(), plan_text(result)) + + +__all__ = ["add_feature_parser", "run_feature_command", "status_record"] diff --git a/ainative/lifecycle/features.py b/ainative/lifecycle/features.py index 0d48e481..724b05dd 100644 --- a/ainative/lifecycle/features.py +++ b/ainative/lifecycle/features.py @@ -123,4 +123,47 @@ def migrate_state(state: statelib.InstallState, return state -__all__ = ["EffectiveState", "project_install_state", "legacy_default", "migrate_state"] +def transition(distribution: manifestlib.Distribution, current: tuple[str, ...], *, + enable: str | None = None, disable: str | None = None, + switch_to: str | None = None) -> tuple[str, ...]: + """The feature set a requested transition produces, or a refusal. + + Exactly one of `enable` / `disable` / `switch_to` is given. A transition is + idempotent: asking for the set the project already has returns it + unchanged. Enabling a feature that conflicts with an active one refuses + with the remedy stated — the conflict is never resolved by an implicit + disable. `switch_to="none"` is the Generic Git shape: it removes every + work forge and nothing else. + """ + + requested = [value for value in (enable, disable) if value is not None] + if len(requested) + (1 if switch_to is not None else 0) != 1: + raise ValueError( + "a feature transition takes exactly one of enable/disable/switch_to") + + if enable is not None: + candidate = distribution.feature(enable) + for name in current: + if name in candidate.conflicts: + raise LifecycleError( + "FEATURE_CONFLICT", + f"feature {enable!r} conflicts with the active feature {name!r}; " + f"use `ainative feature switch {enable}`", + requested=enable, active=name) + return current if enable in current else current + (enable,) + + if disable is not None: + distribution.feature(disable) + return tuple(name for name in current if name != disable) + + if switch_to == "none": + return tuple(name for name in current if not distribution.feature(name).work_forge) + + distribution.feature(switch_to) + conflicts = set(distribution.feature(switch_to).conflicts) + kept = tuple(name for name in current if name not in conflicts and name != switch_to) + return kept + (switch_to,) + + +__all__ = ["EffectiveState", "project_install_state", "legacy_default", + "migrate_state", "transition"] diff --git a/ainative/lifecycle/installer.py b/ainative/lifecycle/installer.py index 7fa2c547..4c4882af 100644 --- a/ainative/lifecycle/installer.py +++ b/ainative/lifecycle/installer.py @@ -65,6 +65,7 @@ def to_record(self) -> dict: "transaction": self.transaction, "notices": list(self.notices), "active_profile": self.state.active_profile if self.state else None, + "active_features": sorted(self.state.active_features) if self.state else None, } if self.legacy is not None and self.legacy.detected: record["legacy_adoption"] = self.legacy.to_record() @@ -109,14 +110,17 @@ def _fresh_state(source: DistributionSource, profile: str, active_features=[featureslib.legacy_default(distribution)]) -def _commit_state(project: Path, state: InstallState, plan: Plan, - distribution: Distribution, source: DistributionSource, - journal_id: str) -> None: - """Fold a successfully applied plan into the install state.""" +def _fold_plan(state: InstallState, plan: Plan, distribution: Distribution, + target_features: tuple[str, ...] | None) -> None: + """Record a plan's outcome in the install state (no write, no metadata).""" for identifier in plan.components_removed: state.drop_component(identifier) - for identifier in distribution.effective_component_ids(plan.to_profile or state.active_profile): + if target_features is not None: + state.active_features = sorted(target_features) + features = tuple(state.active_features) + for identifier in plannerlib.wanted_component_ids( + distribution, plan.to_profile or state.active_profile, features): component = distribution.component(identifier) entries = plannerlib.managed_entries(component, plan.changes) # A CONFLICT leaves the file alone; keep whatever we already knew about @@ -134,6 +138,22 @@ def _commit_state(project: Path, state: InstallState, plan: Plan, if identifier not in state.installed_components: state.installed_components.append(identifier) + +def _commit_state(project: Path, state: InstallState, plan: Plan, + distribution: Distribution, source: DistributionSource, + journal_id: str, *, + target_features: tuple[str, ...] | None = None) -> None: + """Fold a successfully applied plan into the install state.""" + + _fold_plan(state, plan, distribution, target_features) + if plan.to_profile and plan.to_profile != state.active_profile: + state.previous_profile = state.active_profile + state.active_profile = plan.to_profile + state.stack_version = source.version + state.source_version = source.version + state.last_transaction = journal_id + statelib.save(project, state) + if plan.to_profile and plan.to_profile != state.active_profile: state.previous_profile = state.active_profile state.active_profile = plan.to_profile @@ -151,7 +171,8 @@ def plan_profile(project: Path, distribution: Distribution, source: Distribution distribution.profile(target_profile) # refuse an unknown profile up front current = state if state is not None else statelib.load(project) adoption = legacylib.Adoption(False, (), (), (), ()) - if current is None: + fresh = current is None + if fresh: current = _fresh_state(source, target_profile, distribution) adoption = legacylib.adopt(project, distribution, source, target_profile) current = legacylib.apply_to_state(current, adoption, stack_version=source.version) @@ -161,7 +182,8 @@ def plan_profile(project: Path, distribution: Distribution, source: Distribution # that is already absent stays absent. current = featureslib.migrate_state(current, distribution) plan = plannerlib.build_install_plan(project, distribution, source, current, - target_profile, operation=operation) + target_profile, operation=operation, + seed_feature_files=fresh) return plan, current, adoption @@ -266,5 +288,70 @@ def switch(project: Path, target_profile: str, **kwargs) -> OperationResult: return install(project, target_profile, operation="profile-switch", **kwargs) -__all__ = ["OperationResult", "install", "switch", "plan_profile", "require_project", - "trust_anchor_present", "TRUST_ANCHOR_RELATIVE", "VERIFIED_BOOTSTRAP_NOTICE"] +def set_features(project: Path, *, enable: str | None = None, disable: str | None = None, + switch_to: str | None = None, dry_run: bool = False, + distribution: Distribution | None = None, + source: DistributionSource | None = None, + force_unlock: bool = False) -> OperationResult: + """Enable, disable or switch features in one lifecycle transaction. + + The sequence is the profile path's, unchanged (ADR-0017 section 6): take + the project lifecycle lock, reload the state, project the effective V2 set, + plan, apply, verify, write the state last, commit. A V1 state migrates + inside the same transaction and the migration plans no file change of its + own, so a managed file that is already absent stays absent. + """ + + from . import features as featureslib + from . import lock as locklib + + project = require_project(project) + distribution = distribution or manifestlib.load() + source = source or sourcelib.resolve() + _blocking_transaction(project) + + with locklib.acquire(project, "feature", force=force_unlock): + _blocking_transaction(project) + state = statelib.load(project) + if state is None: + raise LifecycleError("NOT_INSTALLED", + f"no AI Native installation recorded in {project}") + was_legacy = state.schema_version < statelib.SCHEMA_VERSION + state = featureslib.migrate_state(state, distribution) + effective = featureslib.project_install_state(state, distribution) + current = effective.active_features + target = featureslib.transition(distribution, current, enable=enable, + disable=disable, switch_to=switch_to) + enabling = [name for name in target if name not in current] + notices = [f"feature {name!r} enabled" for name in enabling] + notices += [f"feature {name!r} disabled" for name in current + if name not in target] + plan = plannerlib.build_install_plan(project, distribution, source, state, + state.active_profile, operation="feature", + target_features=target, + seed_feature_files=bool(enabling)) + if plan.is_noop or dry_run: + if not dry_run: + # A no-op transition changes nothing — including the state + # bytes — unless it still has something to record: the schema + # migration, a feature set that moved, or component records the + # plan proved are gone (a disable whose files were all + # preserved must still drop its records). + needs_recording = (was_legacy or tuple(state.active_features) != target + or bool(plan.components_removed)) + if needs_recording: + _fold_plan(state, plan, distribution, target) + statelib.save(project, state) + return OperationResult("feature", plan, applied=False, dry_run=dry_run, + state=state, notices=notices) + applier = txnlib.Applier(project, distribution, source, plan) + journal = applier.run(lambda: _commit_state(project, state, plan, distribution, + source, applier.journal.identifier, + target_features=target)) + return OperationResult("feature", plan, applied=True, dry_run=False, state=state, + transaction=journal.identifier, notices=notices) + + +__all__ = ["OperationResult", "install", "switch", "set_features", "plan_profile", + "require_project", "trust_anchor_present", "TRUST_ANCHOR_RELATIVE", + "VERIFIED_BOOTSTRAP_NOTICE"] diff --git a/ainative/lifecycle/planner.py b/ainative/lifecycle/planner.py index 63ebdcbb..fa637cea 100644 --- a/ainative/lifecycle/planner.py +++ b/ainative/lifecycle/planner.py @@ -14,6 +14,7 @@ from . import digest as digestlib from . import external_json +from . import features as featureslib from . import hooks as hookslib from . import manifest as manifestlib from .errors import LifecycleError @@ -180,8 +181,14 @@ def component_files(component: Component, def _install_change(project: Path, destination: str, source_relative: str | None, component: Component, source: DistributionSource, - state: InstallState, payload: bytes | None) -> Change: - """One file's decision, taken from ownership and the recorded digest.""" + state: InstallState, payload: bytes | None, *, + seed_missing: bool) -> Change: + """One file's decision, taken from ownership and the recorded digest. + + `seed_missing=False` is the feature-component policy: a feature seeds its + files when it is enabled, so a file the user removed stays removed through + ordinary maintenance (ADR-0017 section 5). Profile components always seed. + """ target = resolve_within(project, destination) known = state.file_for(destination) @@ -203,6 +210,10 @@ def _install_change(project: Path, destination: str, source_relative: str | None "seeded from the shipped template", source_relative, new_digest) if not target.exists(): + if not seed_missing: + return Change(SKIP, destination, component.identifier, component.ownership, + "absent — a feature seeds its files when it is enabled", + source_relative, None) return Change(CREATE, destination, component.identifier, component.ownership, "absent", source_relative, new_digest) @@ -259,7 +270,8 @@ def _prune_changes(project: Path, component: Component, wanted: Iterable[str], def plan_component_install(project: Path, component: Component, source: DistributionSource, - state: InstallState, profile: str) -> list[Change]: + state: InstallState, profile: str, *, + seed_missing: bool = True) -> list[Change]: if component.kind == manifestlib.KIND_DATA_ROOT: return [Change(SKIP, path, component.identifier, component.ownership, "user data root — declared, never written", kind="data_root") @@ -301,7 +313,7 @@ def plan_component_install(project: Path, component: Component, source: Distribu if component.kind == manifestlib.KIND_MARKER: payload = marker_payload(component, source, profile).encode("utf-8") changes.append(_install_change(project, destination, source_relative, component, - source, state, payload)) + source, state, payload, seed_missing=seed_missing)) wanted.append(destination) changes.extend(_prune_changes(project, component, wanted, state)) return changes @@ -389,12 +401,47 @@ def plan_component_removal(project: Path, component: Component, state: InstallSt for entry in state.files_for_component(component.identifier)] +def wanted_component_ids(distribution: Distribution, profile: str, + features: tuple[str, ...]) -> list[str]: + """Profile components first, then the active features' components. + + One owner for the wanted set: the planner builds the plan from it and the + installer records the outcome from it, so the two cannot drift into + planning one set and recording another. + """ + + wanted = list(distribution.effective_component_ids(profile)) + for name in features: + for identifier in distribution.feature(name).components: + if identifier not in wanted: + wanted.append(identifier) + return wanted + + def build_install_plan(project: Path, distribution: Distribution, source: DistributionSource, state: InstallState, target_profile: str, *, - operation: str) -> Plan: - """Install or switch to `target_profile`, changing only what differs.""" + operation: str, + target_features: tuple[str, ...] | None = None, + seed_feature_files: bool = False) -> Plan: + """Install or switch to `target_profile`, changing only what differs. + + `target_features` names the features the project must end up with; None + keeps the effective set the state projects to (which is how a V1 state's + legacy default keeps its files maintained instead of being removed as + "no longer wanted"). Feature components are planned like any other managed + file, with one difference: their absent files are seeded only when a + feature is being enabled (`seed_feature_files`), so a file the user removed + stays removed through ordinary maintenance. + """ + + if target_features is None: + effective = featureslib.project_install_state(state, distribution) + target_features = effective.active_features if effective else () + feature_components: set[str] = set() + for name in target_features: + feature_components.update(distribution.feature(name).components) - wanted = distribution.effective_component_ids(target_profile) + wanted = wanted_component_ids(distribution, target_profile, target_features) present = list(state.installed_components) plan = Plan(operation=operation, project=project, from_profile=state.active_profile if present else None, @@ -402,8 +449,9 @@ def build_install_plan(project: Path, distribution: Distribution, source: Distri for identifier in wanted: component = distribution.component(identifier) + seed = identifier not in feature_components or seed_feature_files plan.changes.extend(plan_component_install(project, component, source, state, - target_profile)) + target_profile, seed_missing=seed)) if identifier not in present: plan.components_added.append(identifier) diff --git a/docs/DISTRIBUTION-LIFECYCLE.md b/docs/DISTRIBUTION-LIFECYCLE.md index 0a01c9ab..72670b7a 100644 --- a/docs/DISTRIBUTION-LIFECYCLE.md +++ b/docs/DISTRIBUTION-LIFECYCLE.md @@ -731,3 +731,36 @@ report whether machine integration is present. The machine surface has no project ownership state and no transaction journal; re-running the installer *is* the update path. + + +## 18. Features (project-scope capabilities) + +A profile is a governance level; a feature is an optional project-scope +capability. They are independent (ADR-0017): a `verified` project can be +Generic Git, and a `standard` project can work with GitLab. + +```bash +ainative feature status +ainative feature enable forge-gitlab +ainative feature disable forge-gitlab +ainative feature switch forge-gitlab # one transaction: disable + enable +ainative feature switch none # Generic Git: no work forge +``` + +At most one **work forge** may be active. `forge-github` (the default) conflicts +with `forge-gitlab`; enabling a conflicting feature refuses with the remedy +(`feature switch`), and a state that names two work forges refuses as +`STATE_CONFLICTING_WORK_FORGE_FEATURES` rather than picking one. + +State schema V2 records `active_features`. A V1 state has no such field and +projects to `forge-github` — the compatibility default, read-only. The +migration happens inside the next mutation, state-last like every other state +write, and plans no file change of its own: a previously managed file that is +already absent stays absent. Only an explicit feature transition seeds files. + +Feature files follow the same ownership rules as everything else: enabling +seeds absent files, disabling removes only unchanged files and preserves files +the user modified, and re-enabling never overwrites a user file. One difference +from profile components is deliberate: ordinary `init`/`update` maintenance +never re-seeds an absent feature file — a file you removed stays removed until +a feature transition puts it back. diff --git a/tests/test_lifecycle_features.py b/tests/test_lifecycle_features.py index 19076815..ef299d41 100644 --- a/tests/test_lifecycle_features.py +++ b/tests/test_lifecycle_features.py @@ -280,5 +280,149 @@ def test_the_effective_feature_set_is_identical_across_the_migration(self): self.assertEqual(before.active_profile, after.active_profile) +class FeatureSwitching(LifecycleTestCase): + """One transaction per transition; ownership decides every file.""" + + TEMPLATES = { + "github": {"ISSUE_TEMPLATE/bug.md": "# bug\n", + "ISSUE_TEMPLATE/feature.md": "# feature\n", + "PULL_REQUEST_TEMPLATE.md": "# pr\n"}, + "gitlab": {"issue_templates/bug.md": "# bug\n", + "issue_templates/feature.md": "# feature\n", + "merge_request_templates/default.md": "# mr\n"}, + } + + def setUp(self) -> None: + super().setUp() + for forge, files in self.TEMPLATES.items(): + for relative, content in files.items(): + write_text(self.distribution_root / "templates" / forge / relative, content) + self.install("standard") + + def switch_features(self, **kwargs): + from ainative.lifecycle import installer + + return installer.set_features(self.project, distribution=self.distribution, + source=self.source, **kwargs) + + def features_now(self) -> list[str]: + return statelib.load(self.project).active_features + + def state_digest(self) -> str: + from ainative.lifecycle.digest import digest_file + + return digest_file(statelib.state_path(self.project)) + + def test_switch_replaces_the_work_forge_in_one_transaction(self): + result = self.switch_features(switch_to=GITLAB) + self.assertTrue(result.applied) + self.assertEqual(self.features_now(), [GITLAB]) + self.assertFalse(self.exists(".github/PULL_REQUEST_TEMPLATE.md"), + "the previous forge's template survived the switch") + self.assertTrue(self.exists(".gitlab/issue_templates/bug.md")) + self.assertEqual(self.read(".gitlab/issue_templates/bug.md"), "# bug\n") + state = statelib.load(self.project) + self.assertEqual([entry.path for entry + in state.files_for_component("gitlab-templates")], + [".gitlab/issue_templates/bug.md", + ".gitlab/issue_templates/feature.md", + ".gitlab/merge_request_templates/default.md"]) + + def test_switch_none_leaves_generic_git(self): + self.switch_features(switch_to="none") + self.assertEqual(self.features_now(), []) + self.assertFalse(self.exists(".github/PULL_REQUEST_TEMPLATE.md")) + + def test_disable_preserves_a_modified_file(self): + self.write(".github/PULL_REQUEST_TEMPLATE.md", "# mine\n") + self.switch_features(disable=LEGACY) + self.assertEqual(self.features_now(), []) + self.assertEqual(self.read(".github/PULL_REQUEST_TEMPLATE.md"), "# mine\n") + self.assertFalse(self.exists(".github/ISSUE_TEMPLATE/bug.md"), + "an unchanged template was not removed by disable") + + def test_enable_seeds_the_absent_files(self): + self.switch_features(switch_to="none") + self.switch_features(enable=GITLAB) + self.assertTrue(self.exists(".gitlab/merge_request_templates/default.md")) + + def test_enable_of_an_active_feature_is_a_no_op(self): + before = self.state_digest() + result = self.switch_features(enable=LEGACY) + self.assertFalse(result.applied) + self.assertTrue(result.plan.is_noop) + self.assertEqual(self.state_digest(), before) + + def test_enable_of_a_conflicting_feature_names_the_remedy(self): + before = self.state_digest() + with self.assertRaises(LifecycleError) as raised: + self.switch_features(enable=GITLAB) + self.assertEqual(raised.exception.code, "FEATURE_CONFLICT") + self.assertIn(f"feature switch {GITLAB}", raised.exception.message) + self.assertEqual(self.features_now(), [LEGACY]) + self.assertEqual(self.state_digest(), before) + + def test_an_unknown_feature_is_refused(self): + with self.assertRaises(LifecycleError) as raised: + self.switch_features(enable="forge-sourceforge") + self.assertEqual(raised.exception.code, "FEATURE_UNKNOWN") + + def test_a_feature_command_needs_an_installed_project(self): + empty = self.root / "empty" + empty.mkdir() + from ainative.lifecycle import installer + + with self.assertRaises(LifecycleError) as raised: + installer.set_features(empty, disable=LEGACY, distribution=self.distribution, + source=self.source) + self.assertEqual(raised.exception.code, "NOT_INSTALLED") + + def test_an_absent_feature_file_stays_absent_through_an_install(self): + self.switch_features(switch_to=GITLAB) + (self.project / ".gitlab" / "issue_templates" / "bug.md").unlink() + self.install("standard") + self.assertFalse(self.exists(".gitlab/issue_templates/bug.md"), + "an install re-seeded a feature file the user removed") + self.switch_features(switch_to="none") + self.switch_features(enable=GITLAB) + self.assertTrue(self.exists(".gitlab/issue_templates/bug.md"), + "an explicit enable did not seed the feature's files") + + def test_the_migration_does_not_resurrect_an_absent_template(self): + """ADR-0017 section 5: the fixture pinned by PR-1B.""" + + path = statelib.state_path(self.project) + record = json.loads(path.read_text(encoding="utf-8")) + record["schema_version"] = 1 + record.pop("active_features", None) + statelib.write_atomic(path, json.dumps(record, indent=2, sort_keys=True) + "\n") + (self.project / ".github" / "PULL_REQUEST_TEMPLATE.md").unlink() + self.install("standard") + self.assertFalse(self.exists(".github/PULL_REQUEST_TEMPLATE.md"), + "the migration resurrected an absent managed file") + state = statelib.load(self.project) + self.assertEqual(state.schema_version, statelib.SCHEMA_VERSION) + self.assertEqual(state.active_features, [LEGACY]) + + def test_a_round_trip_loses_no_user_data(self): + self.write(".github/ISSUE_TEMPLATE/bug.md", "# mine\n") + self.switch_features(switch_to=GITLAB) + self.switch_features(switch_to="none") + self.switch_features(enable=LEGACY) + self.assertEqual(self.read(".github/ISSUE_TEMPLATE/bug.md"), "# mine\n") + self.assertEqual(self.features_now(), [LEGACY]) + self.assertTrue(self.exists(".github/PULL_REQUEST_TEMPLATE.md"), + "the unmodified templates were not restored by the enable") + + def test_the_cli_switches_and_reports(self): + status = json.loads(self.cli("feature", "status", "--json").stdout) + self.assertEqual(status["active_features"], [LEGACY]) + self.assertTrue(status["installed"]) + switched = json.loads(self.cli("feature", "switch", GITLAB, "--json").stdout) + self.assertTrue(switched["applied"]) + self.assertEqual(switched["active_features"], [GITLAB]) + self.assertEqual(self.features_now(), [GITLAB]) + + if __name__ == "__main__": unittest.main() From 4f2464f870a9e8cdac1dc3722d54af9a686be271 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Wed, 16 Sep 2026 18:35:01 +0200 Subject: [PATCH 03/14] test(lifecycle): feature ownership matrix - a user file is never adopted (#162) --- tests/test_lifecycle_features.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_lifecycle_features.py b/tests/test_lifecycle_features.py index ef299d41..2adfef9a 100644 --- a/tests/test_lifecycle_features.py +++ b/tests/test_lifecycle_features.py @@ -414,6 +414,19 @@ def test_a_round_trip_loses_no_user_data(self): self.assertTrue(self.exists(".github/PULL_REQUEST_TEMPLATE.md"), "the unmodified templates were not restored by the enable") + def test_a_user_file_where_a_feature_file_belongs_is_never_adopted(self): + self.write(".gitlab/issue_templates/bug.md", "# mine\n") + result = self.switch_features(switch_to=GITLAB) + self.assertEqual(self.read(".gitlab/issue_templates/bug.md"), "# mine\n") + conflicts = [change.path for change in result.plan.changes + if change.action == "CONFLICT"] + self.assertEqual(conflicts, [".gitlab/issue_templates/bug.md"]) + self.assertTrue(self.exists(".gitlab/issue_templates/feature.md"), + "the other files of the enabled feature were not seeded") + state = statelib.load(self.project) + self.assertIsNone(state.file_for(".gitlab/issue_templates/bug.md"), + "the user's file was adopted into the state") + def test_the_cli_switches_and_reports(self): status = json.loads(self.cli("feature", "status", "--json").stdout) self.assertEqual(status["active_features"], [LEGACY]) From 058182ecd25a347def4e0085cc8bd821d2a132c5 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Wed, 16 Sep 2026 18:50:22 +0200 Subject: [PATCH 04/14] feat(forge): pure work-authority resolver, claim grammar and durable journal (#163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0018's local half: the harness observes remote facts, this layer records them deterministically and never decides by guessing. - ainative/forge.py: `resolve_observed_work_authority()` — pure (no network, no credentials, no writes, no push authorization). Priority: explicit reference, then harness declaration, then exactly one compatible observed candidate; otherwise WORK_AUTHORITY_UNAVAILABLE / _AMBIGUOUS / _MISMATCH. Only github.com and gitlab.com are read as providers; a self-hosted host is `unknown`, never inferred from its name; a fork is ambiguity, never "prefer origin". - ainative/claims.py: canonical identities (`:principal:`, `::`), UTC-normalized timestamps (a naive one refuses), total winner ordering with CLAIM_CONFLICT for unorderable duplicates, and the ClaimAttempt journal under `.ai-native/state/claim-attempts/` — PENDING written durably before the remote POST, outcomes CONFIRMED/LOST/CONFLICT/UNCERTAIN/ABANDONED, an unwritable or corrupt journal fails closed, abandon is an explicit local operator transition that keeps the record, and an abandoned record is final. - ainative/claim_cli.py + `ainative claim-attempt list|inspect|abandon --confirm`: the recovery surface. No retry path exists by design. - New refusal codes: WORK_AUTHORITY_*, CLAIM_INVALID, CLAIM_CONFLICT, CLAIM_UNCERTAIN, CLAIM_JOURNAL_UNAVAILABLE. - Tests: 27 in tests/test_forge_claims.py, including the invariant that a pending attempt survives a lifecycle update byte-identically and the CLI confirmation flow. CI runs them in the lifecycle job. Verified: 308 lifecycle tests OK, knowledge/machine/CLI suites OK, gates green (conventions, scope, complexity, LOC). Refs #163 --- .github/workflows/ci.yml | 3 + ainative/claim_cli.py | 76 +++++++++ ainative/claims.py | 321 +++++++++++++++++++++++++++++++++++ ainative/cli.py | 10 ++ ainative/forge.py | 169 ++++++++++++++++++ ainative/lifecycle/errors.py | 10 ++ tests/test_forge_claims.py | 280 ++++++++++++++++++++++++++++++ 7 files changed, 869 insertions(+) create mode 100644 ainative/claim_cli.py create mode 100644 ainative/claims.py create mode 100644 ainative/forge.py create mode 100644 tests/test_forge_claims.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ed4c129..30181fe1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -204,6 +204,9 @@ jobs: - name: Features, State V2 and the shared projection run: python -m unittest tests.test_lifecycle_features -v + - name: Work authority, claim grammar and the claim journal + run: python -m unittest tests.test_forge_claims -v + - name: Path traversal, link escape, tampered state, archive safety run: python -m unittest tests.test_lifecycle_security -v diff --git a/ainative/claim_cli.py b/ainative/claim_cli.py new file mode 100644 index 00000000..a5ec065c --- /dev/null +++ b/ainative/claim_cli.py @@ -0,0 +1,76 @@ +"""The `ainative claim-attempt` recovery surface: list, inspect, abandon. + +Recovery is an operator act, so the surface is read-mostly: listing and +inspecting never change anything, and abandoning is one explicit transition +that requires `--confirm`. There is deliberately no "retry" here — an +uncertain POST is resolved by searching the remote for the attempt marker, +never by posting again (ADR-0018 section 6). +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Callable + +from . import claims + + +def add_claim_parser(commands) -> None: + claim = commands.add_parser( + "claim-attempt", help="Inspect or abandon journaled claim attempts.") + subcommands = claim.add_subparsers(dest="claim_command", required=True) + listing = subcommands.add_parser("list", help="Every journaled claim attempt.") + _common(listing) + inspect = subcommands.add_parser("inspect", help="One attempt, in full.") + inspect.add_argument("attempt_id") + _common(inspect) + abandon = subcommands.add_parser( + "abandon", help="Give up an unresolved attempt (local transition only).") + abandon.add_argument("attempt_id") + abandon.add_argument("--confirm", action="store_true", + help="required: this is an operator decision") + _common(abandon) + + +def _common(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--project", type=Path, default=None, + help="project root (default: the current directory)") + parser.add_argument("--json", action="store_true", help="machine-readable output") + + +def _attempt_text(attempt: claims.ClaimAttempt) -> str: + lines = [f"{attempt.attempt_id}: {attempt.state}", + f" authority: {attempt.authority.get('provider')}:" + f"{attempt.authority.get('project')}", + f" item: {attempt.item}", + f" principal: {attempt.principal}", + f" marker: {attempt.marker}", + f" created: {attempt.created_at}"] + if attempt.outcome_at: + lines.append(f" outcome: {attempt.outcome_at}") + if attempt.note: + lines.append(f" note: {attempt.note}") + return "\n".join(lines) + + +def run_claim_command(args: argparse.Namespace, *, project: Path, + report: Callable[[dict, str], int]) -> int: + if args.claim_command == "list": + attempts = claims.list_attempts(project) + record = {"attempts": [attempt.to_record() for attempt in attempts], + "unresolved": [attempt.attempt_id for attempt in attempts + if attempt.state in claims.UNRESOLVED]} + lines = [f"{len(attempts)} attempt(s), {len(record['unresolved'])} unresolved"] + for attempt in attempts: + lines.append(f" {attempt.state:<10} {attempt.attempt_id} " + f"item {attempt.item}") + return report(record, "\n".join(lines)) + if args.claim_command == "inspect": + attempt = claims.load_attempt(project, args.attempt_id) + return report(attempt.to_record(), _attempt_text(attempt)) + attempt = claims.abandon(project, args.attempt_id, confirm=args.confirm) + return report(attempt.to_record(), _attempt_text(attempt)) + + +__all__ = ["add_claim_parser", "run_claim_command"] diff --git a/ainative/claims.py b/ainative/claims.py new file mode 100644 index 00000000..6bed3813 --- /dev/null +++ b/ainative/claims.py @@ -0,0 +1,321 @@ +"""Canonical claim identities, and the durable journal that survives a crash. + +A claim is a remote mutation: an agent asks a work authority to recognize it +as the owner of a WorkItem. What makes a crash recoverable is that the local +record of the attempt exists *before* the remote POST — journal first, then +exactly one POST, then a re-read that searches for the attempt's own marker +(ADR-0018 sections 3–6). + +This module owns the local half: canonical identities, UTC-normalized +ordering, the PENDING → outcome journal under `.ai-native/state/claim-attempts/`, +and the explicit operator transitions. It never calls a provider API, never +holds a credential, and never infers an outcome: the harness observes, this +module records and arbitrates deterministically. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path + +from .lifecycle import state as statelib +from .lifecycle.errors import LifecycleError + +KIND_COMMENT = "comment" +KIND_NOTE = "note" +KIND_ASSIGNMENT = "assignment" +EVENT_KINDS = (KIND_COMMENT, KIND_NOTE, KIND_ASSIGNMENT) + +PENDING = "PENDING" +CONFIRMED = "CONFIRMED" +LOST = "LOST" +CONFLICT = "CONFLICT" +UNCERTAIN = "UNCERTAIN" +ABANDONED = "ABANDONED" +OUTCOMES = (CONFIRMED, LOST, CONFLICT, UNCERTAIN, ABANDONED) +UNRESOLVED = (PENDING, UNCERTAIN) + +ATTEMPTS_RELATIVE = Path(".ai-native") / "state" / "claim-attempts" +MARKER_PREFIX = "ainative-claim-attempt:" + +_ATTEMPT_ID = re.compile(r"^claim_[0-9a-f]{32}$") +_TOKEN = re.compile(r"^[^\s:]+$") + + +def _token(value: str, what: str) -> str: + if not isinstance(value, str) or not _TOKEN.match(value): + raise LifecycleError("CLAIM_INVALID", + f"a claim {what} must be a non-empty token without " + f"whitespace or ':', got {value!r}") + return value + + +def principal(provider: str, stable_id: str) -> str: + """`:principal:` — who is claiming.""" + + return f"{_token(provider, 'provider')}:principal:{_token(stable_id, 'principal id')}" + + +def event_identifier(provider: str, kind: str, stable_id: str) -> str: + """`::` — which claim event it is.""" + + if kind not in EVENT_KINDS: + raise LifecycleError("CLAIM_INVALID", + f"unknown claim event kind {kind!r} " + f"(known: {', '.join(EVENT_KINDS)})") + return f"{_token(provider, 'provider')}:{kind}:{_token(stable_id, 'event id')}" + + +def to_utc(value: str) -> str: + """An ISO-8601 timestamp normalized to UTC. A naive one is refused. + + Ordering claims by timestamp is only deterministic if every timestamp + carries its zone; "2026-09-16T10:00:00" cannot be compared with + "2026-09-16T09:00:00+02:00" without guessing. + """ + + try: + moment = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError as error: + raise LifecycleError("CLAIM_INVALID", + f"claim timestamp {value!r} is not ISO-8601") from error + if moment.tzinfo is None: + raise LifecycleError("CLAIM_INVALID", + f"claim timestamp {value!r} carries no timezone") + return moment.astimezone(timezone.utc).isoformat() + + +@dataclass(frozen=True) +class ClaimEvent: + """One observed claim signal, in canonical form.""" + + identifier: str # canonical event identifier + created_at: str # UTC ISO-8601 + actor: str # canonical principal + + def sort_key(self) -> tuple[str, str]: + return (self.created_at, self.identifier) + + def to_record(self) -> dict: + return {"identifier": self.identifier, "created_at": self.created_at, + "actor": self.actor} + + +def winner(events) -> ClaimEvent | None: + """The winning claim by `(created_at_utc, canonical_event_identifier)`. + + Exact duplicates are one event. Two distinct events with the same key — a + duplicate identifier carrying different actors — are unorderable and refuse + as `CLAIM_CONFLICT`: every claimant stops (ADR-0018 section 3). + """ + + unique: dict[str, ClaimEvent] = {} + for event in events: + existing = unique.get(event.identifier) + if existing is None: + unique[event.identifier] = event + elif existing != event: + raise LifecycleError( + "CLAIM_CONFLICT", + f"two different claim events share the identifier " + f"{event.identifier!r}; they cannot be ordered", + identifier=event.identifier) + if not unique: + return None + ordered = sorted(unique.values(), key=ClaimEvent.sort_key) + if len(ordered) > 1 and ordered[0].sort_key() == ordered[1].sort_key(): + raise LifecycleError("CLAIM_CONFLICT", + "two claim events are unorderable: identical " + "timestamp and identifier") + return ordered[0] + + +@dataclass +class ClaimAttempt: + """One journaled attempt to claim a WorkItem.""" + + attempt_id: str + authority: dict # WorkAuthorityRef.to_record() + item: str # the WorkItem identifier on that authority + principal: str # canonical principal + marker: str # exact marker the remote signal must carry + state: str = PENDING + created_at: str = "" + outcome_at: str | None = None + note: str = "" + history: list = field(default_factory=list) + + def to_record(self) -> dict: + return {"attempt_id": self.attempt_id, "authority": dict(self.authority), + "item": self.item, "principal": self.principal, "marker": self.marker, + "state": self.state, "created_at": self.created_at, + "outcome_at": self.outcome_at, "note": self.note, + "history": list(self.history)} + + @classmethod + def from_record(cls, raw: object) -> "ClaimAttempt": + if not isinstance(raw, dict): + raise LifecycleError("CLAIM_JOURNAL_UNAVAILABLE", + "a claim attempt record is not an object") + attempt_id = raw.get("attempt_id") + if not isinstance(attempt_id, str) or not _ATTEMPT_ID.match(attempt_id): + raise LifecycleError("CLAIM_JOURNAL_UNAVAILABLE", + "a claim attempt record carries an invalid id") + if not isinstance(raw.get("authority"), dict) or not isinstance(raw.get("item"), str): + raise LifecycleError("CLAIM_JOURNAL_UNAVAILABLE", + f"claim attempt {attempt_id} is malformed") + if not isinstance(raw.get("principal"), str) or not isinstance(raw.get("marker"), str): + raise LifecycleError("CLAIM_JOURNAL_UNAVAILABLE", + f"claim attempt {attempt_id} is malformed") + state = raw.get("state") + if state not in (PENDING, *OUTCOMES): + raise LifecycleError("CLAIM_JOURNAL_UNAVAILABLE", + f"claim attempt {attempt_id} carries an unknown state " + f"{state!r}") + return cls(attempt_id=attempt_id, authority=dict(raw["authority"]), + item=raw["item"], principal=raw["principal"], marker=raw["marker"], + state=state, created_at=str(raw.get("created_at") or ""), + outcome_at=raw.get("outcome_at"), note=str(raw.get("note") or ""), + history=list(raw.get("history") or [])) + + +def attempts_root(project: Path) -> Path: + return Path(project) / ATTEMPTS_RELATIVE + + +def new_attempt(*, authority, item: str, principal: str, note: str = "") -> ClaimAttempt: + """A fresh attempt with its deterministic remote marker. Not yet journaled.""" + + attempt_id = statelib.new_identifier("claim") + return ClaimAttempt(attempt_id=attempt_id, authority=authority.to_record(), + item=_token(item, "item"), principal=principal, + marker=f"{MARKER_PREFIX}{attempt_id}", note=note) + + +def _path(project: Path, attempt_id: str) -> Path: + if not isinstance(attempt_id, str) or not _ATTEMPT_ID.match(attempt_id): + raise LifecycleError("CLAIM_INVALID", + f"invalid claim attempt id {attempt_id!r}") + return attempts_root(project) / f"{attempt_id}.json" + + +def _write(project: Path, attempt: ClaimAttempt) -> None: + """One durable write of an attempt record, or a fail-closed refusal.""" + + path = _path(project, attempt.attempt_id) + try: + path.parent.mkdir(parents=True, exist_ok=True) + statelib.write_atomic(path, json.dumps(attempt.to_record(), indent=2, + sort_keys=True) + "\n") + except OSError as error: + raise LifecycleError( + "CLAIM_JOURNAL_UNAVAILABLE", + f"cannot write the claim journal at {path}: {error}") from error + + +def begin(project: Path, attempt: ClaimAttempt) -> Path: + """Write the PENDING attempt durably. This happens BEFORE the remote POST. + + A local write that fails is `CLAIM_JOURNAL_UNAVAILABLE` and the caller + must not POST at all: an attempt nobody recorded cannot be recovered. + """ + + attempt.state = PENDING + attempt.created_at = attempt.created_at or statelib.now() + attempt.history.append({"state": PENDING, "at": attempt.created_at}) + _write(project, attempt) + return _path(project, attempt.attempt_id) + + +def load_attempt(project: Path, attempt_id: str) -> ClaimAttempt: + path = _path(project, attempt_id) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as error: + raise LifecycleError("CLAIM_JOURNAL_UNAVAILABLE", + f"no claim attempt {attempt_id!r} is journaled") from error + except (OSError, ValueError) as error: + raise LifecycleError( + "CLAIM_JOURNAL_UNAVAILABLE", + f"claim attempt {attempt_id!r} is unreadable; nothing may infer " + f"its outcome: {error}") from error + return ClaimAttempt.from_record(payload) + + +def list_attempts(project: Path) -> list[ClaimAttempt]: + """Every journaled attempt, or a refusal — a corrupt journal is never skipped. + + An unreadable entry might be the one that says a POST is in flight; listing + the others and pretending it is not there is how a second POST happens. + """ + + root = attempts_root(project) + if not root.is_dir(): + return [] + attempts: list[ClaimAttempt] = [] + for path in sorted(root.glob("*.json")): + attempts.append(load_attempt(project, path.stem)) + return attempts + + +def unresolved(project: Path) -> list[ClaimAttempt]: + return [attempt for attempt in list_attempts(project) + if attempt.state in UNRESOLVED] + + +def record_outcome(project: Path, attempt_id: str, outcome: str, *, note: str = "") -> ClaimAttempt: + """Persist what the re-read proved. Never invents an outcome.""" + + if outcome not in OUTCOMES: + raise LifecycleError("CLAIM_INVALID", + f"unknown claim outcome {outcome!r} " + f"(known: {', '.join(OUTCOMES)})") + attempt = load_attempt(project, attempt_id) + if attempt.state == ABANDONED: + raise LifecycleError("CLAIM_INVALID", + f"attempt {attempt_id} was abandoned; its record is final " + "(abandonment is an operator decision, not a retry slot)") + attempt.state = outcome + attempt.outcome_at = statelib.now() + if note: + attempt.note = f"{attempt.note}\n{note}".strip() + attempt.history.append({"state": outcome, "at": attempt.outcome_at}) + _write(project, attempt) + return attempt + + +def abandon(project: Path, attempt_id: str, *, confirm: bool) -> ClaimAttempt: + """The explicit operator transition. Local only; the record is retained. + + Only an unresolved attempt (PENDING or UNCERTAIN) can be abandoned: a + CONFIRMED claim is real on the remote, and hiding it locally would not + release it. After abandonment a new attempt is allowed. + """ + + if not confirm: + raise LifecycleError("CONFIRMATION_REQUIRED", + "abandoning a claim attempt is an explicit operator " + "decision; pass --confirm after inspecting the remote state") + attempt = load_attempt(project, attempt_id) + if attempt.state not in UNRESOLVED: + raise LifecycleError("CLAIM_INVALID", + f"attempt {attempt_id} is {attempt.state}; only an " + "unresolved attempt can be abandoned") + attempt.state = ABANDONED + attempt.outcome_at = statelib.now() + attempt.history.append({"state": ABANDONED, "at": attempt.outcome_at}) + _write(project, attempt) + return attempt + + +__all__ = [ + "KIND_COMMENT", "KIND_NOTE", "KIND_ASSIGNMENT", "EVENT_KINDS", + "PENDING", "CONFIRMED", "LOST", "CONFLICT", "UNCERTAIN", "ABANDONED", + "OUTCOMES", "UNRESOLVED", "ATTEMPTS_RELATIVE", "MARKER_PREFIX", + "principal", "event_identifier", "to_utc", "ClaimEvent", "winner", + "ClaimAttempt", "attempts_root", "new_attempt", "begin", "load_attempt", + "list_attempts", "unresolved", "record_outcome", "abandon", +] diff --git a/ainative/cli.py b/ainative/cli.py index e55460b4..0f0eccac 100644 --- a/ainative/cli.py +++ b/ainative/cli.py @@ -177,9 +177,11 @@ def build_parser() -> argparse.ArgumentParser: from ainative.knowledge.cli import add_context_parser, add_knowledge_parser from ainative.lifecycle.feature_cli import add_feature_parser + from ainative.claim_cli import add_claim_parser add_knowledge_parser(commands) add_context_parser(commands) add_feature_parser(commands) + add_claim_parser(commands) for name in VERIFIED_COMMANDS: commands.add_parser(name, add_help=False, @@ -316,6 +318,13 @@ def _cmd_feature(args: argparse.Namespace) -> int: plan_text=_plan_text) +def _cmd_claim_attempt(args: argparse.Namespace) -> int: + from .claim_cli import run_claim_command + + return run_claim_command(args, project=_project(args), + report=lambda record, text: _report(args, record, text)) + + def _confirm_purge(args: argparse.Namespace, project: Path) -> bool: """Interactive confirmation. Without a TTY this is always False, so `--yes` is the only way through — no CI ever blocks on a prompt.""" @@ -711,6 +720,7 @@ def _cmd_setup(args: argparse.Namespace) -> int: "context": _cmd_context, "profile": _cmd_profile, "feature": _cmd_feature, + "claim-attempt": _cmd_claim_attempt, "status": _cmd_status, "doctor": _cmd_doctor, "repair": _cmd_repair, diff --git a/ainative/forge.py b/ainative/forge.py new file mode 100644 index 00000000..8074e7cd --- /dev/null +++ b/ainative/forge.py @@ -0,0 +1,169 @@ +"""Resolve the Work Authority from locally observed facts — purely. + +Multi-Forge's Work Authority is the remote whose work records are +authoritative for a project: one provider plus a project identity on it. +Deciding *which* remote that is, is the only thing this module does, and it +decides it from facts observed elsewhere — an explicit WorkItem/ChangeRequest +reference, a harness declaration, or the Git remotes someone already read. It +never reaches the network, never looks up a credential, never writes, and never +authorizes a push (ADR-0018). + +The rules are ordered and fail closed (ADR-0018 section 2): + +1. an explicit WorkItem/ChangeRequest repository reference; +2. an explicit harness-declared authority; +3. exactly one compatible observed candidate; +4. otherwise a refusal — never a guess. + +A fork (origin and upstream disagreeing) is `AMBIGUOUS`, never "prefer +origin". A self-hosted host is `unknown`, never inferred from its name: +GitHub.com and GitLab.com are the only hosted forges V1 knows, and inferring +"gitlab" from a hostname is how a generic Git project gets claimed on a forge +it never chose. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable +from urllib.parse import urlsplit + +from .lifecycle.errors import LifecycleError + +PROVIDER_GITHUB = "github" +PROVIDER_GITLAB = "gitlab" +PROVIDER_UNKNOWN = "unknown" + +# The only hosts whose provider is a fact rather than a guess. +HOST_HINTS = {"github.com": PROVIDER_GITHUB, "gitlab.com": PROVIDER_GITLAB} + + +@dataclass(frozen=True) +class WorkAuthorityRef: + """One provider plus a stable project identity. Not a URL, not a hostname.""" + + provider: str + project: str + + def to_record(self) -> dict: + return {"provider": self.provider, "project": self.project} + + def __str__(self) -> str: + return f"{self.provider}:{self.project}" + + +@dataclass(frozen=True) +class ObservedRemote: + """A Git remote someone read locally. Evidence, never authority.""" + + name: str + url: str + + @property + def provider(self) -> str: + return provider_hint(self.url) + + @property + def identity(self) -> str | None: + return project_identity(self.url) + + def to_record(self) -> dict: + return {"name": self.name, "url": self.url, "provider": self.provider, + "project": self.identity} + + +def provider_hint(url: str) -> str: + """The provider a remote URL *names*: a hosted forge, or `unknown`.""" + + host, _ = _host_and_path(url) + return HOST_HINTS.get(host, PROVIDER_UNKNOWN) + + +def project_identity(url: str) -> str | None: + """`owner/name` (or a GitLab subgroup path) for a hosted URL, else None.""" + + host, path = _host_and_path(url) + if host not in HOST_HINTS: + return None + cleaned = path.strip("/") + if cleaned.endswith(".git"): + cleaned = cleaned[: -len(".git")] + segments = [segment for segment in cleaned.split("/") if segment] + if len(segments) < 2: + return None + return "/".join(segments) + + +def _host_and_path(url: str) -> tuple[str, str]: + """Host and path for both URL shapes Git uses, without guessing a provider.""" + + if "://" in url: + parts = urlsplit(url) + return (parts.hostname or "").lower(), parts.path + # scp-like: [user@]host:path + head, _, path = url.partition(":") + host = head.rpartition("@")[2] + return host.lower(), path + + +def observe_remotes(remotes: Iterable[tuple[str, str]]) -> tuple[ObservedRemote, ...]: + """(name, url) pairs as observed remotes; a thin adapter for callers.""" + + return tuple(ObservedRemote(name=name, url=url) for name, url in remotes) + + +def resolve_observed_work_authority(*, + explicit: WorkAuthorityRef | None = None, + declared: WorkAuthorityRef | None = None, + remotes: Iterable[ObservedRemote] = (), + ) -> WorkAuthorityRef: + """The authority the ordered rules accept, or a refusal that says why. + + `explicit` is a WorkItem/ChangeRequest repository reference (priority 1), + `declared` a harness statement (priority 2), `remotes` locally observed + evidence (priority 3). Two statements that disagree refuse as + `WORK_AUTHORITY_MISMATCH`; observation that is not unambiguous refuses as + `WORK_AUTHORITY_AMBIGUOUS` (a fork) or `WORK_AUTHORITY_UNAVAILABLE` + (nothing usable was observed). + """ + + if explicit is not None and declared is not None and explicit != declared: + raise LifecycleError( + "WORK_AUTHORITY_MISMATCH", + f"the explicit reference names {explicit} but the harness declares " + f"{declared}; reconcile them before mutating remote state", + explicit=explicit.to_record(), declared=declared.to_record()) + if explicit is not None: + return explicit + if declared is not None: + return declared + + candidates: list[WorkAuthorityRef] = [] + for remote in remotes: + if remote.provider == PROVIDER_UNKNOWN or remote.identity is None: + continue + candidate = WorkAuthorityRef(provider=remote.provider, project=remote.identity) + if candidate not in candidates: + candidates.append(candidate) + + if len(candidates) == 1: + return candidates[0] + observed = [remote.to_record() for remote in remotes] + if not candidates: + raise LifecycleError( + "WORK_AUTHORITY_UNAVAILABLE", + "no work authority could be resolved locally: no explicit reference, " + "no harness declaration, and no observed remote names a known forge", + remotes=observed) + raise LifecycleError( + "WORK_AUTHORITY_AMBIGUOUS", + "several observed remotes name different work authorities: " + + ", ".join(str(candidate) for candidate in candidates) + + "; declare the authority explicitly before mutating remote state", + candidates=[candidate.to_record() for candidate in candidates], + remotes=observed) + + +__all__ = ["WorkAuthorityRef", "ObservedRemote", "provider_hint", "project_identity", + "observe_remotes", "resolve_observed_work_authority", + "PROVIDER_GITHUB", "PROVIDER_GITLAB", "PROVIDER_UNKNOWN", "HOST_HINTS"] diff --git a/ainative/lifecycle/errors.py b/ainative/lifecycle/errors.py index 5e7e1653..0c5c3464 100644 --- a/ainative/lifecycle/errors.py +++ b/ainative/lifecycle/errors.py @@ -47,6 +47,16 @@ # Enabling a feature that conflicts with an active one: the remedy is an # explicit switch, not an implicit disable. "FEATURE_CONFLICT": EXIT_INVALID_REQUEST, + # Work Authority resolution (ADR-0018). Ambiguity stops remote mutation. + "WORK_AUTHORITY_UNAVAILABLE": EXIT_INVALID_REQUEST, + "WORK_AUTHORITY_AMBIGUOUS": EXIT_INVALID_REQUEST, + "WORK_AUTHORITY_MISMATCH": EXIT_INVALID_REQUEST, + # Claims (ADR-0018). The journal is written before the remote POST, and + # an unknown outcome is never retried automatically. + "CLAIM_INVALID": EXIT_INVALID_REQUEST, + "CLAIM_CONFLICT": EXIT_FAILED, + "CLAIM_UNCERTAIN": EXIT_FAILED, + "CLAIM_JOURNAL_UNAVAILABLE": EXIT_FAILED, # Machine-wide integration (`ainative machine`, `ainative setup`). "MACHINE_MANIFEST_UNREADABLE": EXIT_INVALID_REQUEST, "MACHINE_VAULT_PAIR_REQUIRED": EXIT_INVALID_REQUEST, diff --git a/tests/test_forge_claims.py b/tests/test_forge_claims.py new file mode 100644 index 00000000..cde5c3bc --- /dev/null +++ b/tests/test_forge_claims.py @@ -0,0 +1,280 @@ +"""The pure Work Authority resolver, the claim grammar, and the claim journal. + +ADR-0018 splits work management in two: the harness observes remote facts, and +the local layer records them deterministically. These tests pin the local half +— resolution priority and refusals, canonical identities, UTC ordering, the +journal-before-POST invariant, the outcomes, and the explicit abandonment +transition. Nothing here touches the network, and nothing may infer an outcome: +an unreadable journal fails closed. +""" + +from __future__ import annotations + +import json +import unittest +from pathlib import Path + +from tests.lifecycle_support import LifecycleTestCase, write_text +from ainative import claims +from ainative import forge as forgelib +from ainative.lifecycle import state as statelib +from ainative.lifecycle.errors import LifecycleError + + +class RemoteReading(unittest.TestCase): + + def test_https_and_scp_urls_read_the_same(self): + self.assertEqual(forgelib.provider_hint("https://github.com/o/r.git"), "github") + self.assertEqual(forgelib.provider_hint("git@github.com:o/r.git"), "github") + self.assertEqual(forgelib.provider_hint("ssh://git@gitlab.com/g/s/p.git"), "gitlab") + self.assertEqual(forgelib.project_identity("git@github.com:o/r.git"), "o/r") + self.assertEqual(forgelib.project_identity("https://gitlab.com/g/s/p.git"), "g/s/p") + + def test_a_self_hosted_host_is_unknown_never_guessed(self): + self.assertEqual(forgelib.provider_hint("https://git.example.com/o/r.git"), "unknown") + self.assertIsNone(forgelib.project_identity("https://git.example.com/o/r.git")) + + def test_an_url_without_a_project_is_not_an_identity(self): + self.assertIsNone(forgelib.project_identity("https://github.com")) + self.assertIsNone(forgelib.project_identity("https://github.com/only-owner")) + + +class WorkAuthorityResolution(unittest.TestCase): + + def ref(self, provider: str, project: str) -> forgelib.WorkAuthorityRef: + return forgelib.WorkAuthorityRef(provider=provider, project=project) + + def test_an_explicit_reference_wins(self): + resolved = forgelib.resolve_observed_work_authority( + explicit=self.ref("github", "o/explicit"), + remotes=forgelib.observe_remotes([("origin", "https://gitlab.com/o/other.git")])) + self.assertEqual(resolved, self.ref("github", "o/explicit")) + + def test_a_harness_declaration_wins_over_observation(self): + resolved = forgelib.resolve_observed_work_authority( + declared=self.ref("gitlab", "g/declared"), + remotes=forgelib.observe_remotes([("origin", "https://github.com/o/other.git")])) + self.assertEqual(resolved, self.ref("gitlab", "g/declared")) + + def test_contradicting_statements_refuse_as_mismatch(self): + with self.assertRaises(LifecycleError) as raised: + forgelib.resolve_observed_work_authority( + explicit=self.ref("github", "o/a"), declared=self.ref("gitlab", "g/b")) + self.assertEqual(raised.exception.code, "WORK_AUTHORITY_MISMATCH") + + def test_one_compatible_candidate_is_an_authority(self): + resolved = forgelib.resolve_observed_work_authority( + remotes=forgelib.observe_remotes([("origin", "git@github.com:o/r.git")])) + self.assertEqual(resolved, self.ref("github", "o/r")) + + def test_two_names_for_the_same_authority_are_not_ambiguous(self): + resolved = forgelib.resolve_observed_work_authority( + remotes=forgelib.observe_remotes([ + ("origin", "https://github.com/o/r.git"), + ("upstream", "git@github.com:o/r")])) + self.assertEqual(resolved, self.ref("github", "o/r")) + + def test_a_fork_refuses_as_ambiguous_without_preferring_origin(self): + with self.assertRaises(LifecycleError) as raised: + forgelib.resolve_observed_work_authority( + remotes=forgelib.observe_remotes([ + ("origin", "https://github.com/me/fork.git"), + ("upstream", "https://github.com/org/project.git")])) + self.assertEqual(raised.exception.code, "WORK_AUTHORITY_AMBIGUOUS") + self.assertEqual(len(raised.exception.detail["candidates"]), 2) + + def test_nothing_usable_refuses_as_unavailable(self): + with self.assertRaises(LifecycleError) as raised: + forgelib.resolve_observed_work_authority(remotes=()) + self.assertEqual(raised.exception.code, "WORK_AUTHORITY_UNAVAILABLE") + with self.assertRaises(LifecycleError) as raised: + forgelib.resolve_observed_work_authority( + remotes=forgelib.observe_remotes( + [("origin", "https://git.example.com/o/r.git")])) + self.assertEqual(raised.exception.code, "WORK_AUTHORITY_UNAVAILABLE") + + +class ClaimGrammar(unittest.TestCase): + + def event(self, identifier: str, created_at: str, actor: str = "github:principal:alice"): + return claims.ClaimEvent(identifier=identifier, created_at=created_at, actor=actor) + + def test_canonical_identities(self): + self.assertEqual(claims.principal("github", "alice"), + "github:principal:alice") + self.assertEqual(claims.event_identifier("gitlab", "note", "42"), + "gitlab:note:42") + with self.assertRaises(LifecycleError) as raised: + claims.event_identifier("gitlab", "telepathy", "42") + self.assertEqual(raised.exception.code, "CLAIM_INVALID") + for bad in ("", "two words", "co:lon"): + with self.assertRaises(LifecycleError): + claims.principal("github", bad) + + def test_timestamps_normalize_to_utc_and_naive_ones_refuse(self): + self.assertEqual(claims.to_utc("2026-09-16T12:00:00+02:00"), + "2026-09-16T10:00:00+00:00") + self.assertEqual(claims.to_utc("2026-09-16T10:00:00Z"), + "2026-09-16T10:00:00+00:00") + for bad in ("2026-09-16T10:00:00", "not-a-date"): + with self.assertRaises(LifecycleError) as raised: + claims.to_utc(bad) + self.assertEqual(raised.exception.code, "CLAIM_INVALID") + + def test_winner_is_ordered_by_timestamp_then_identifier(self): + early = self.event("github:comment:2", "2026-09-16T09:00:00+00:00") + late = self.event("github:comment:1", "2026-09-16T10:00:00+00:00") + self.assertEqual(claims.winner([late, early]), early) + self.assertIsNone(claims.winner([])) + + def test_a_tie_is_broken_by_the_canonical_identifier(self): + first = self.event("github:comment:1", "2026-09-16T10:00:00+00:00") + second = self.event("github:comment:2", "2026-09-16T10:00:00+00:00") + self.assertEqual(claims.winner([second, first]), first) + + def test_duplicate_observations_are_one_event(self): + event = self.event("github:comment:1", "2026-09-16T10:00:00+00:00") + self.assertEqual(claims.winner([event, event]), event) + + def test_an_identifier_carrying_two_actors_is_a_conflict(self): + with self.assertRaises(LifecycleError) as raised: + claims.winner([ + self.event("github:comment:1", "2026-09-16T10:00:00+00:00", + actor="github:principal:alice"), + self.event("github:comment:1", "2026-09-16T10:00:00+00:00", + actor="github:principal:bob")]) + self.assertEqual(raised.exception.code, "CLAIM_CONFLICT") + + +class ClaimJournal(LifecycleTestCase): + + def authority(self): + return forgelib.WorkAuthorityRef(provider="github", project="o/r") + + def attempt(self, item: str = "42"): + return claims.new_attempt(authority=self.authority(), item=item, + principal=claims.principal("github", "alice")) + + def test_begin_writes_pending_before_anything_else(self): + attempt = self.attempt() + path = claims.begin(self.project, attempt) + self.assertTrue(path.is_file()) + record = json.loads(path.read_text(encoding="utf-8")) + self.assertEqual(record["state"], claims.PENDING) + self.assertIn(attempt.attempt_id, attempt.marker) + self.assertEqual(record["marker"], attempt.marker) + + def test_an_unwritable_journal_refuses_instead_of_posting(self): + journal = claims.attempts_root(self.project) + write_text(journal, "not a directory\n") + with self.assertRaises(LifecycleError) as raised: + claims.begin(self.project, self.attempt()) + self.assertEqual(raised.exception.code, "CLAIM_JOURNAL_UNAVAILABLE") + + def test_a_corrupt_entry_fails_closed(self): + attempt = self.attempt() + claims.begin(self.project, attempt) + write_text(claims.attempts_root(self.project) / f"{attempt.attempt_id}.json", + "{not json\n") + with self.assertRaises(LifecycleError) as raised: + claims.list_attempts(self.project) + self.assertEqual(raised.exception.code, "CLAIM_JOURNAL_UNAVAILABLE") + + def test_outcomes_are_recorded_and_uncertain_stays_unresolved(self): + confirmed = self.attempt() + uncertain = self.attempt(item="43") + claims.begin(self.project, confirmed) + claims.begin(self.project, uncertain) + claims.record_outcome(self.project, confirmed.attempt_id, claims.CONFIRMED) + claims.record_outcome(self.project, uncertain.attempt_id, claims.UNCERTAIN, + note="POST answer was lost") + states = {attempt.attempt_id: attempt.state + for attempt in claims.list_attempts(self.project)} + self.assertEqual(states[confirmed.attempt_id], claims.CONFIRMED) + self.assertEqual(states[uncertain.attempt_id], claims.UNCERTAIN) + self.assertEqual([attempt.attempt_id for attempt in claims.unresolved(self.project)], + [uncertain.attempt_id]) + self.assertEqual(claims.load_attempt(self.project, uncertain.attempt_id).note, + "POST answer was lost") + + def test_an_unknown_outcome_is_refused(self): + attempt = self.attempt() + claims.begin(self.project, attempt) + with self.assertRaises(LifecycleError) as raised: + claims.record_outcome(self.project, attempt.attempt_id, "MAYBE") + self.assertEqual(raised.exception.code, "CLAIM_INVALID") + + def test_abandon_needs_an_explicit_confirmation(self): + attempt = self.attempt() + claims.begin(self.project, attempt) + with self.assertRaises(LifecycleError) as raised: + claims.abandon(self.project, attempt.attempt_id, confirm=False) + self.assertEqual(raised.exception.code, "CONFIRMATION_REQUIRED") + self.assertEqual(claims.load_attempt(self.project, attempt.attempt_id).state, + claims.PENDING) + + def test_abandon_is_local_and_the_record_is_retained(self): + attempt = self.attempt() + claims.begin(self.project, attempt) + abandoned = claims.abandon(self.project, attempt.attempt_id, confirm=True) + self.assertEqual(abandoned.state, claims.ABANDONED) + self.assertTrue((claims.attempts_root(self.project) + / f"{attempt.attempt_id}.json").is_file()) + self.assertEqual(claims.unresolved(self.project), []) + # after abandonment, a new attempt is allowed + replacement = self.attempt() + claims.begin(self.project, replacement) + self.assertEqual(claims.load_attempt(self.project, replacement.attempt_id).state, + claims.PENDING) + + def test_a_confirmed_claim_cannot_be_abandoned(self): + attempt = self.attempt() + claims.begin(self.project, attempt) + claims.record_outcome(self.project, attempt.attempt_id, claims.CONFIRMED) + with self.assertRaises(LifecycleError) as raised: + claims.abandon(self.project, attempt.attempt_id, confirm=True) + self.assertEqual(raised.exception.code, "CLAIM_INVALID") + + def test_an_abandoned_record_is_final(self): + attempt = self.attempt() + claims.begin(self.project, attempt) + claims.abandon(self.project, attempt.attempt_id, confirm=True) + with self.assertRaises(LifecycleError) as raised: + claims.record_outcome(self.project, attempt.attempt_id, claims.LOST) + self.assertEqual(raised.exception.code, "CLAIM_INVALID") + + def test_a_pending_attempt_survives_a_lifecycle_update_byte_identically(self): + from ainative.lifecycle.digest import digest_file + + self.install("standard") + attempt = self.attempt() + path = claims.begin(self.project, attempt) + before = digest_file(path) + from ainative.lifecycle import installer + + installer.install(self.project, "standard", operation="update", + distribution=self.distribution, source=self.source) + self.assertEqual(digest_file(path), before, + "a lifecycle update touched a pending claim attempt") + self.assertEqual(statelib.SCHEMA_VERSION, + statelib.load(self.project).schema_version) + + def test_the_cli_lists_inspects_and_abandons(self): + attempt = self.attempt() + claims.begin(self.project, attempt) + listed = json.loads(self.cli("claim-attempt", "list", "--json").stdout) + self.assertEqual(listed["unresolved"], [attempt.attempt_id]) + inspected = json.loads(self.cli("claim-attempt", "inspect", + attempt.attempt_id, "--json").stdout) + self.assertEqual(inspected["marker"], attempt.marker) + refused = self.cli("claim-attempt", "abandon", attempt.attempt_id) + self.assertEqual(refused.returncode, 2, "abandon without --confirm must refuse") + abandoned = json.loads(self.cli("claim-attempt", "abandon", attempt.attempt_id, + "--confirm", "--json").stdout) + self.assertEqual(abandoned["state"], claims.ABANDONED) + self.assertEqual(json.loads(self.cli("claim-attempt", "list", "--json").stdout) + ["unresolved"], []) + + +if __name__ == "__main__": + unittest.main() From 61cf5409bac5ee2da502adf28a0303b86021cea6 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Wed, 16 Sep 2026 18:54:12 +0200 Subject: [PATCH 05/14] docs(workflow): provider-neutral FORGE-WORKFLOW, GitHub and GitLab mappings (#163) --- docs/FORGE-WORKFLOW.md | 262 +++++++++++++++++++++++++++++++ docs/GITHUB-WORKFLOW.md | 246 +++++++---------------------- docs/GITLAB-WORKFLOW.md | 55 +++++++ tests/test_github_work_skills.py | 6 +- 4 files changed, 377 insertions(+), 192 deletions(-) create mode 100644 docs/FORGE-WORKFLOW.md create mode 100644 docs/GITLAB-WORKFLOW.md diff --git a/docs/FORGE-WORKFLOW.md b/docs/FORGE-WORKFLOW.md new file mode 100644 index 00000000..b6bc9706 --- /dev/null +++ b/docs/FORGE-WORKFLOW.md @@ -0,0 +1,262 @@ +# Forge Workflow — provider-neutral work management + +How AI agents and humans turn ideas into merged, verified work when the work +authority may be GitHub, GitLab, or nothing at all (Generic Git). This file is +the canonical statement of the *generic* rules; each provider's mechanics live +in its mapping — [`GITHUB-WORKFLOW.md`](GITHUB-WORKFLOW.md), +[`GITLAB-WORKFLOW.md`](GITLAB-WORKFLOW.md) — and where a mapping is silent, this +file decides. The architecture review for this document is closed; change it +through an Issue, not by silently editing policy. + +## Vocabulary + +```text +Work Authority the remote whose work records are authoritative for a project: + one provider + one project identity. Not a URL, not a hostname. +WorkItem the unit of work on that authority (GitHub Issue, GitLab WorkItem) +ChangeRequest the reviewable change (GitHub PR, GitLab merge request) +WorkAuthorityRef provider + stable project identity (never guessed from a hostname) +Claim an agent's remote signal that it owns a WorkItem +``` + +One provider, one project, at most: a project has **one** work authority. Two +candidates that disagree (a fork's `origin` and `upstream`) are *ambiguity*, and +ambiguity stops remote mutation — see "Work Authority resolution" below. + +## Where state lives + +```text +Work authority records = canonical actionable backlog (WorkItems and their state) +Provider metadata = labels, milestones, review state (per mapping) +ADRs = accepted architecture (in the repository) +Work Contracts = deterministic verification when policy requires them +Vault / Obsidian = historical context only +Skills = procedures +``` + +AI Native never stores project-specific backlog state: no `issues.json`, no +current-milestone file, no vault board mirroring the authority. If the work +authority is unreachable, the backlog is unreachable with it — that is the +point: one authoritative source per fact. + +### WorkItem vs ADR + +An ADR records an architecture decision already made. A WorkItem records work +that should happen. "Refactor X per ADR-0007" may be a WorkItem; disagreeing +with ADR-0007 is not — it is a proposal to amend the ADR, handled like any +architectural change (WorkItem first, then a deliberate ADR update). + +### WorkItem vs Work Contract + +A Verified Work Contract is the deterministic proof side of one unit of work: +constrained verification, evidence provenance, convergence. It does not +replace the WorkItem; it verifies it. When repository policy requires Verified +work, the issue-to-implementation skill creates or updates the contract after +the claim and binds the WorkItem's acceptance criteria into it. + +### WorkItem vs Vault memory + +The Vault keeps session summaries, research, postmortems, decision context and +links (WorkItem, ChangeRequest, ADR). It must never hold canonical current +state: no current backlog, no status mirrors, no live Kanban, no assignee +snapshots. No bidirectional Vault ↔ authority synchronization exists or should +exist. + +## Lifecycle of one unit of work + +```text +finding --> triage --> WorkItem (or rejected / Discussion / ADR candidate) + | + v + issue-to-implementation + claim -> branch -> implement -> validate + | + v + ChangeRequest with "Refs #N" + | + v + MERGE_READY (all conditions) + | + v + Refs #N -> Closes #N --> FINAL_MERGE_FRESHNESS + | + v + merge (authorized process) + | + v + WorkItem closed as completed --> provider state = Done +``` + +### Triage + +Ideas, bugs, audit findings and research observations are candidates, not +backlog. Triage validates, deduplicates, classifies and decides: WorkItem, +Discussion, ADR candidate, or reject. Only useful, actionable work is +persisted. Triage never implements code. + +### Implementation + +One skill owns the path from a claimed WorkItem to a merged ChangeRequest. Its +invariants: + +1. **Claim before working.** See "Claims" below. +2. **Scope is the WorkItem.** Implement only its canonical scope. +3. **Acceptance Criteria are protected.** An implementation agent MUST NOT + weaken, remove, replace or materially reinterpret acceptance criteria to + make its implementation pass. It MAY detect ambiguity, identify + infeasibility, propose a change or request clarification — but a material + change becomes authoritative only after explicit approval from an + authorized maintainer **and** persistence in the WorkItem. Material = + required behavior, functional scope, security requirements, performance + thresholds, supported platforms, failure behavior, public API/contract, + acceptance thresholds. If uncertain, treat it as material and ask. There is + no separate AC database: the WorkItem body is the only home of the AC. +4. **Policy conflicts stop work.** Root `AGENTS.md` is the repository baseline; + local `AGENTS.md` files specialize or strengthen it and must never silently + weaken it. A real contradiction is surfaced as a visible conflict — never + resolved silently. +5. **Refs while working, Closes when ready.** Open and update ChangeRequests + with `Refs #N` during development. `Closes #N` appears only once + MERGE_READY is satisfied, and only immediately before the merge. + +After canonical scope and AC are bound, implementation choices use +`implementation-economy`; it cannot alter scope or acceptance criteria. + +### MERGE_READY + +`MERGE_READY` is a checklist, not a merge: + +```text +MERGE_READY = + current WorkItem acceptance criteria satisfied + + required tests/checks pass + + documentation updated where required + + review requirements satisfied + + no relevant blocker + + Verified CONVERGED when policy requires it + + WorkItem scope/AC freshness confirmed +``` + +### FINAL_MERGE_FRESHNESS + +Immediately before the actual merge, re-read the WorkItem and its current +acceptance criteria and compare against the latest validated snapshot (Verified +work: compare the canonical AC digest with the digest bound in the Work +Contract). A material difference invalidates MERGE_READY: `ISSUE_CHANGED` — do +not merge, do not close, reconcile scope and verify again. This is not an +atomic remote transaction; it is the freshest possible pre-merge check. It +exists because the WorkItem is shared state and another actor may have changed +it while the ChangeRequest was in review. + +### DONE + +```text +DONE = MERGE_READY was valid + + ChangeRequest merged + + WorkItem closed as completed + + provider state = Done (when the mapping defines one) +``` + +Closed is not Done: WorkItems are closed as duplicate, not-planned, invalid or +superseded too. Only "closed as completed" after a merged ChangeRequest counts. +If the provider exposes a completion state but the automation lacks permission +to update it, report `PROJECT_STATUS_SYNC_REQUIRED` instead of claiming Done +silently. + +## Work Authority resolution + +Reading and mutating remote state is the harness's job (it holds the provider +tooling and the credentials). `ainative` adds one pure local resolver, +`resolve_observed_work_authority()` (`ainative/forge.py`): no network, no +credentials, no writes, no push authorization. Priority: + +```text +1. an explicit WorkItem/ChangeRequest repository reference +2. an explicit harness-declared authority +3. exactly one compatible observed candidate +4. otherwise: WORK_AUTHORITY_UNAVAILABLE | WORK_AUTHORITY_AMBIGUOUS | WORK_AUTHORITY_MISMATCH +``` + +Observed candidates are diagnostic only. `github.com` and `gitlab.com` are the +only hosts whose provider is a fact; a self-hosted host is `unknown`, never +inferred from its name. When the resolution is ambiguous, remote mutation stops +— diagnostics may continue. + +## Claims + +There is no distributed lock and no lock service. A claim is a remote signal +and a local record: + +- **Preferred signal:** assignment. **Fallback:** an explicit claim comment or + note. The mapping names the provider's exact signals. +- **Identity is canonical**, never local formatting: + `:principal:` for the claimant, + `::` for the event (`comment`, `note`, + `assignment`). Timestamps are normalized to UTC. Winner ordering is the total + order `(created_at_utc, canonical_event_identifier)`; an unorderable pair is + `CLAIM_CONFLICT` and every claimant stops. +- **Before claiming**, read the WorkItem and any linked active ChangeRequest: a + linked open implementation ChangeRequest is a claim-level conflict + (`ACTIVE_PR_CONFLICT`) — stop, unless a maintainer requested a competing + attempt or the ChangeRequest was explicitly abandoned. +- **Journal before POST.** `ainative claim-attempt` writes a PENDING record + under `.ai-native/state/claim-attempts/` *before* the remote signal, with a + deterministic marker the signal must carry. A journal that cannot be written + is `CLAIM_JOURNAL_UNAVAILABLE` and no signal is sent. +- **Outcomes:** `CONFIRMED`, `LOST`, `CONFLICT`, `UNCERTAIN`, `ABANDONED`. An + unknown POST result is `UNCERTAIN` and is **never** retried automatically: + the marker search on the remote is the only resolution. A corrupt journal + fails closed. +- **Abandonment is explicit:** `ainative claim-attempt abandon --confirm` + is a local transition that keeps the record; only then may a new attempt be + created. No leases, no heartbeats, no age-based expiration: time is not + authority. + +## Review scope + +A ChangeRequest review checks: the WorkItem's acceptance criteria, the relevant +`AGENTS.md` policy, the relevant ADRs, and regressions the change introduces or +worsens. Anything else found during review is handled by priority and never +expands the current change: + +```text +P0/P1 -> separate WorkItem / escalation; may block a release globally, + does not expand the change +P2/P3 -> backlog candidate; does not expand the change +``` + +Only a finding caused or worsened by the current change — or one that directly +makes its merge unsafe — may block that change. + +## Deferred work + +Significant deferred work must not live only in chat, a temporary plan, or +historical memory. If it is actionable and worth retaining, it becomes a +WorkItem; if it was explicitly rejected, no WorkItem is created. + +## Branch naming + +Recommended, where the provider allows free branch names: + +```text +feat/-description fix/-description +docs/-description refactor/-description +chore/description # truly trivial internal work, no WorkItem required +``` + +Naming is a convention, not an enforcement mechanism. + +## Mappings + +| Concept | GitHub | GitLab | +|---|---|---| +| WorkItem | Issue | WorkItem (issue) | +| ChangeRequest | Pull request | Merge request | +| Claim signal | assignment, issue comment | assignee, note | +| Claim event id | comment/assignment event id | note/assignment id | +| Review state | PR review | MR approval | +| Templates | `templates/github/` → `.github/` | `templates/gitlab/` → `.gitlab/` | + +Each mapping document records its provider's exact mechanics and points back +here for the rules. `github-templates` and `gitlab-templates` are the two +feature components that install them (ADR-0017). diff --git a/docs/GITHUB-WORKFLOW.md b/docs/GITHUB-WORKFLOW.md index 71663569..1fc3f617 100644 --- a/docs/GITHUB-WORKFLOW.md +++ b/docs/GITHUB-WORKFLOW.md @@ -1,26 +1,26 @@ -# GitHub Workflow — AI-centered work management +# GitHub Workflow — the GitHub.com mapping -How AI agents and humans turn ideas into merged, verified work using GitHub as -the canonical active state. The architecture review for this document is -closed; change it through an issue, not by silently editing policy. +The generic rules — vocabulary, lifecycle, claims, MERGE_READY, +FINAL_MERGE_FRESHNESS, DONE, review scope, deferred work, branch naming — live +in [`FORGE-WORKFLOW.md`](FORGE-WORKFLOW.md), with the GitLab mapping in +[`GITLAB-WORKFLOW.md`](GITLAB-WORKFLOW.md). This file records only what is +GitHub-specific. The architecture review for this document is closed; change +it through an Issue, not by silently editing policy. -## Where state lives +## Where GitHub state lives ```text GitHub Issues = canonical actionable backlog GitHub Project = operational visualization when configured Milestones = delivery grouping -ADRs = accepted architecture -Work Contracts = deterministic verification when policy requires them -Vault / Obsidian = historical context only -Skills = procedures (github-triage, issue-to-implementation, - implementation-economy) ``` AI Native never stores project-specific backlog state. There is no -`issues.json`, no `current-milestone.json`, no vault board that mirrors GitHub. -If GitHub is unreachable, the backlog is unreachable with it — that is the -point: one authoritative source per fact. +`issues.json`, no `current-milestone.json`, no vault board that mirrors +GitHub. If GitHub is unreachable, the backlog is unreachable with it — that is +the point: one authoritative source per fact. (The generic statement, and the +WorkItem vs ADR / Work Contract / Vault memory rules, are in +`FORGE-WORKFLOW.md`.) ### Issue vs Project @@ -33,9 +33,9 @@ canonical operational fields: | Status | `Inbox`, `Backlog`, `Ready`, `In Progress`, `Done` | | Area | project-specific metadata (subsystem, domain) | -Do not add `Priority`, `Type`, `Effort`, or `Review` fields in v1. Type and -priority are Issue labels (`type:bug`, `priority:P1`, ...) — labels travel with -the Issue everywhere, Project fields do not. PR state stays GitHub's native PR +Do not add `Priority`, `Type`, `Effort`, or `Review` fields: Type and priority +are Issue labels (`type:bug`, `priority:P1`, ...) — labels travel with the +Issue everywhere, Project fields do not. PR state stays GitHub's native PR state; it is not a Project column. A repository without a Project loses nothing but the visualization. Every @@ -43,157 +43,26 @@ skill must degrade gracefully when no Project exists: Issues, labels and PRs are the whole workflow for small repositories. Never fail because a Project is absent. -### Issue vs ADR - -An ADR records an architecture decision already made. An Issue records work -that should happen. "Refactor X per ADR-0007" may be an Issue; disagreeing -with ADR-0007 is not — it is a proposal to amend the ADR, handled like any -architectural change (issue first, then a deliberate ADR update). - -### Issue vs Work Contract - -A Verified Work Contract is the deterministic proof side of one unit of work: -constrained verification, evidence provenance, convergence. It does not -replace the Issue; it verifies it. When repository policy requires Verified -work, the issue-to-implementation skill creates or updates the contract after -the claim and binds the Issue's acceptance criteria into it. - -### Issue vs Vault memory - -The Vault keeps session summaries, research, postmortems, decision context and -links (Issue, PR, ADR). It must never hold canonical current state: no current -backlog, no Issue status mirrors, no live Kanban, no assignee snapshots. No -bidirectional Vault <-> GitHub synchronization exists or should exist. - -## Lifecycle of one unit of work +## Claim signals ```text -finding --> github-triage --> Issue (or rejected / Discussion / ADR candidate) - | - v - issue-to-implementation - claim -> branch -> implement -> validate - | - v - PR with "Refs #N" - | - v - MERGE_READY (all conditions) - | - v - Refs #N -> Closes #N --> FINAL_MERGE_FRESHNESS - | - v - merge (authorized process) - | - v - Issue closed as completed --> Project Status = Done +preferred: assign the Issue to the claiming account +fallback: an explicit Issue comment: + "Claiming this Issue for implementation." ``` -### Triage (skills/github-triage) - -Ideas, bugs, audit findings and research observations are candidates, not -backlog. Triage validates, deduplicates, classifies (`type:*`, `priority:P*`) -and decides: Issue, Discussion, ADR candidate, or reject. Only useful, -actionable work is persisted. P0/P1 validated findings may be escalated into -Issues when authorized; P2 findings may become Issues when useful; P3 and -research notes are not automatically created as Issues. Triage never -implements code. - -### Implementation (skills/issue-to-implementation) - -One skill owns the path from a claimed Issue to a merged PR. Its invariants: - -1. **Claim before working.** See "Multi-agent claims" below. -2. **Scope is the Issue.** Implement only the Issue's canonical scope. -3. **Acceptance Criteria are protected.** An implementation agent MUST NOT - weaken, remove, replace or materially reinterpret an Issue's Acceptance - Criteria to make its implementation pass. It MAY detect ambiguity, - identify infeasibility, propose a change or request clarification — but a - material AC change becomes authoritative only after explicit approval from - an authorized maintainer/user **and** persistence in the GitHub Issue. - Material changes include: required behavior, functional scope, security - requirements, performance thresholds, supported platforms, failure - behavior, public API/contract, acceptance thresholds. If uncertain, treat - the change as material and ask. There is no separate AC database: the - Issue body is the only home of the AC. -4. **Policy conflicts stop work.** Root AGENTS.md is the repository baseline; - local AGENTS.md files specialize or strengthen it and must never silently - weaken it. A real contradiction is surfaced as a visible conflict (on the - Issue when working from one: conflicting files, conflicting rules, why - implementation cannot proceed safely) — never resolved silently. ADRs and - AGENTS.md govern different domains; a genuine contradiction between them - requires reconciliation, not arbitrary precedence. -5. **Refs while working, Closes when ready.** Open and update PRs with - `Refs #N` during development. `Closes #N` appears only once MERGE_READY is - satisfied, and only immediately before the merge. - -After canonical scope and AC are bound, implementation choices use -`implementation-economy`; it cannot alter Issue scope or Acceptance Criteria. - -### MERGE_READY - -`MERGE_READY` is a checklist, not a merge: - -```text -MERGE_READY = - current Issue acceptance criteria satisfied - + required tests/checks pass - + documentation updated where required - + review requirements satisfied - + no relevant blocker - + Verified CONVERGED when policy requires it - + Issue scope/AC freshness confirmed -``` - -### FINAL_MERGE_FRESHNESS - -Immediately before the actual merge, re-read the Issue and its current -acceptance criteria and compare against the latest validated snapshot -(Verified work: compare the canonical AC digest with the digest bound in the -Work Contract). A material difference invalidates MERGE_READY: -`ISSUE_CHANGED` — do not merge, do not close, reconcile scope and verify -again. - -This is not an atomic GitHub transaction; it is the freshest possible -pre-merge check. It exists because the Issue is shared state and another -actor may have changed it while the PR was in review. - -### DONE - -```text -DONE = MERGE_READY was valid - + PR merged - + Issue closed as completed - + Project Status = Done (when a Project is configured) -``` - -Closed is not Done. Issues are closed as `duplicate`, `not planned`, -`invalid` or `superseded` too — only "closed as completed" after a merged PR -counts as Done. If a Project exists but the automation lacks permission to -update it, report `PROJECT_STATUS_SYNC_REQUIRED` instead of claiming Done -silently. - -## Multi-agent claims - -There is no distributed lock and no lock service. The claim is GitHub state: - -- **Preferred:** GitHub Issue assignment. -- **Fallback** (actor cannot assign): an explicit Issue claim comment, e.g. - "Claiming this Issue for implementation." - Valid claim signals normalize to: `actor`, `created_at`, `stable_github_identifier` (comment or assignment event ID), `claim_kind`. -Before claiming, also read the open PRs that reference or implement the -Issue (PR body `Refs`/`Closes`/`Fixes`, issue timeline links, or an explicit +Before claiming, also read the open PRs that reference or implement the Issue +(PR body `Refs`/`Closes`/`Fixes`, issue timeline links, or an explicit "implements #N"): **an active linked implementation PR is a claim-level conflict** (`ACTIVE_PR_CONFLICT` -> STOP). It lifts only when explicit: a maintainer requested a competing implementation or collaboration, the PR was explicitly abandoned or superseded, or the same actor is continuing their own -implementation. Age alone never proves abandonment; an ambiguous -relationship is surfaced, not duplicated around. After claiming, re-read the -Issue — the claim is only as good as the freshest read, and a PR that -appeared during the race re-triggers the conflict. +implementation. Age alone never proves abandonment; an ambiguous relationship +is surfaced, not duplicated around. After claiming, re-read the Issue — the +claim is only as good as the freshest read, and a PR that appeared during the +race re-triggers the conflict. Resolution (implemented by `skills/issue-to-implementation/bin/claim_resolution.py`): @@ -211,53 +80,42 @@ unorderable -> CLAIM_CONFLICT -> every claimant STOPs ``` Assignment preference is an acquisition rule, not an arbitration override. -Stale-claim expiration, stale-PR expiration, heartbeats and leases are out -of scope in v1. - -## Review scope - -A PR review checks: the Issue's acceptance criteria, the relevant AGENTS.md -policy, the relevant ADRs, and regressions the PR introduces or worsens. -Anything else found during review is handled by priority and never expands -the current PR: +Stale-claim expiration, stale-PR expiration, heartbeats and leases are out of +scope in v1 (issue #33 stays independent). The journal-before-POST rule and +the recovery surface (`ainative claim-attempt`) are in `FORGE-WORKFLOW.md`. -```text -P0/P1 -> separate Issue / escalation; may block a release globally, - does not expand the PR -P2/P3 -> backlog candidate (github-triage); does not expand the PR -``` - -Only a finding caused or worsened by the current PR — or one that directly -makes its merge unsafe — may block that PR. - -## Deferred work - -Significant deferred work must not live only in chat, a temporary plan, or -historical memory. If it is actionable and worth retaining, it becomes a -GitHub Issue; if it was explicitly rejected, no Issue is created. +## Implementation, on GitHub -## Branch naming +The generic invariants — claim before working, scope is the Issue, protected +acceptance criteria, policy conflicts stop work, `Refs` while working and +`Closes` only when ready — are stated once in `FORGE-WORKFLOW.md`. After scope +and AC are bound, implementation choices use `implementation-economy`; its +limits are stated there too. -Recommended: +## MERGE_READY and DONE, on GitHub -```text -feat/123-description fix/123-description -docs/123-description refactor/123-description -chore/description # truly trivial internal work, no Issue required -``` +`MERGE_READY` and `FINAL_MERGE_FRESHNESS` are defined once in +`FORGE-WORKFLOW.md`; on GitHub the linkage mechanics are: -Naming is a convention, not an enforcement mechanism, in v1. +- PRs start with `Refs #N`; `Closes #N` appears only once MERGE_READY holds, + only immediately before the merge; +- DONE additionally requires the Project Status to be `Done` when a Project is + configured; +- if the automation lacks permission to update the Project, report + `PROJECT_STATUS_SYNC_REQUIRED` instead of claiming Done silently. ## Templates Generic templates ship with the stack (`templates/github/`) and are installed -as managed files (individual files, never the whole `.github/` directory): +as managed files (individual files, never the whole `.github/` directory) by +the `github-templates` component, owned by the `forge-github` feature +(ADR-0017): - `.github/ISSUE_TEMPLATE/bug.md` — Problem, Reproduction, Expected outcome, Acceptance criteria, Affected surface (triage hint only; Project Area is canonical when a Project is used). -- `.github/ISSUE_TEMPLATE/feature.md` — Problem, Expected outcome, - Acceptance criteria, Out of scope. +- `.github/ISSUE_TEMPLATE/feature.md` — Problem, Expected outcome, Acceptance + criteria, Out of scope. - `.github/PULL_REQUEST_TEMPLATE.md` — What changed, Why, Refs #, Validation, Risk. It deliberately contains no `Closes`: development starts with `Refs`. @@ -266,3 +124,9 @@ is preserved; a stack-installed template the user modified is preserved and reported as a conflict; a stack-installed, unmodified template may be updated or removed by update/uninstall. See `tests/test_lifecycle_github_templates.py` for the executable contract. + +## Review scope + +The generic rule (only findings caused or worsened by the current change may +block it; P0/P1 escalate separately, P2/P3 become backlog candidates) is stated +once in `FORGE-WORKFLOW.md`. diff --git a/docs/GITLAB-WORKFLOW.md b/docs/GITLAB-WORKFLOW.md new file mode 100644 index 00000000..33dddbf0 --- /dev/null +++ b/docs/GITLAB-WORKFLOW.md @@ -0,0 +1,55 @@ +# GitLab Workflow — the GitLab.com mapping + +The generic rules — vocabulary, lifecycle, claims, MERGE_READY, DONE, review +scope, deferred work — live in [`FORGE-WORKFLOW.md`](FORGE-WORKFLOW.md). This +file records only what is GitLab-specific. GitLab Self-Managed is not a +declared V1 target: it stays `UNTESTED` until qualified (ADR-0019). + +## Records + +| Generic concept | GitLab record | +|---|---| +| WorkItem | issue (work item) | +| ChangeRequest | merge request | +| Backlog state | labels (e.g. `type:*`, `priority:*`), milestones, boards | +| Review state | MR approval; merge when pipeline succeeds, per project settings | +| Completion | issue closed; board/list membership | + +GitLab calls its unit of work a *work item* natively; the generic vocabulary +uses the same word deliberately — the mapping is nearly identity for records, +and the interesting differences are claim signals and templates. + +## Claim signals + +```text +preferred: assign the issue to the claiming account +fallback: a note on the issue: "Claiming this work item for implementation." +``` + +The canonical event kinds are `note` (GitLab notes are comments) and +`assignment`. A claim event's stable identifier is the note's id or the +assignee-change event id; the claimant principal is +`gitlab:principal:` (the numeric user id, not the username — +a username can be renamed). Timestamps arrive with a zone; normalize with +`to_utc`. + +The resolution rule itself (earliest valid claim, deterministic ordering) is in +`FORGE-WORKFLOW.md`; no GitLab-specific resolution exists. + +## Templates + +`gitlab-templates` (feature `forge-gitlab`, ADR-0017) installs individual +managed files from `templates/gitlab/`: + +```text +.gitlab/issue_templates/bug.md +.gitlab/issue_templates/feature.md +.gitlab/merge_request_templates/default.md +``` + +They are `MANAGED_MUTABLE`: a re-install updates a file still holding the +bytes the stack wrote, preserves a file the user edited (as a conflict), and +never adopts a file it did not write. The GitLab provider itself (release +distribution through the GitLab Releases API and the Generic Package Registry) +is defined by ADR-0019 and implemented by PR-5; this document covers work +management, not release distribution. diff --git a/tests/test_github_work_skills.py b/tests/test_github_work_skills.py index 3a8b0bda..0a073de0 100644 --- a/tests/test_github_work_skills.py +++ b/tests/test_github_work_skills.py @@ -458,8 +458,12 @@ def test_boy_scout_under_15_min_rule_is_gone(self) -> None: self.assertIn('Scoped cleanup', self.AGENTS) def test_github_workflow_lists_implementation_economy(self) -> None: + # The generic rule moved to the provider-neutral policy (ADR-0018); + # the GitHub mapping must still name the skill and point at it. self.assertIn('implementation-economy', self.WORKFLOW) - self.assertIn('it cannot alter Issue scope or Acceptance Criteria', self.WORKFLOW) + neutral = (REPO / 'docs' / 'FORGE-WORKFLOW.md').read_text(encoding='utf-8') + self.assertIn('implementation-economy', neutral) + self.assertIn('it cannot alter scope or acceptance criteria', neutral) def test_preexisting_deletion_imperatives_are_scoped(self) -> None: From a5d275eaae5971f31819d6255738f934140c6e35 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Wed, 16 Sep 2026 19:09:56 +0200 Subject: [PATCH 06/14] docs(policy): distribute a provider-neutral AGENTS.md template (#163) The engineering-method component installs templates/AGENTS.md (Work Authority vocabulary, ADR-0018) instead of the repository's own AGENTS.md, which stays GitHub-specific. Identical to the root file except the work-management section; the fixture distribution ships both files. --- ainative/lifecycle/data/components.json | 4 +- templates/AGENTS.md | 686 ++++++++++++++++++++++++ tests/lifecycle_support.py | 3 + 3 files changed, 691 insertions(+), 2 deletions(-) create mode 100644 templates/AGENTS.md diff --git a/ainative/lifecycle/data/components.json b/ainative/lifecycle/data/components.json index 83ae584a..cee436fb 100644 --- a/ainative/lifecycle/data/components.json +++ b/ainative/lifecycle/data/components.json @@ -43,12 +43,12 @@ "engineering-method": { "version": 1, "kind": "file", - "source": "AGENTS.md", + "source": "templates/AGENTS.md", "destination": "AGENTS.md", "ownership": "MANAGED_MUTABLE", "required": true, "title": "Engineering method", - "description": "The shared cross-tool engineering rules every agent reads." + "description": "The shared cross-tool engineering rules every agent reads. The distributed copy (templates/AGENTS.md) speaks provider-neutral Work Authority; this repository's own AGENTS.md stays GitHub-specific (ADR-0018)." }, "conventions": { "version": 1, diff --git a/templates/AGENTS.md b/templates/AGENTS.md new file mode 100644 index 00000000..f895318d --- /dev/null +++ b/templates/AGENTS.md @@ -0,0 +1,686 @@ +# Universal Engineering Rules + + + + + + +## Primary bias to correct + +Working code is not clean code. Small pieces are not simple. Familiar patterns are not correct patterns. +Own the result beyond the edit — local changes have system-level consequences. + +--- + +## Code structure + +- **File size**: flag >500 LOC new file; propose extraction >800 LOC existing; mandatory refactor >1500 LOC +- **Function size**: ≤50 LOC target; >100 alert; >200 blocking — extract sub-functions, never keep adding +- **Cyclomatic complexity**: ≤10 target; >15 alert; >25 blocking +- **Single responsibility**: before adding to a file — "does this belong here?", "am I adding a second responsibility?", "is this helper reusable elsewhere?" +- **No global state**: no `static` globals, no singletons (`getInstance()`). Prefer injection via parameter or owner member. If unavoidable: `// WHY: [precise technical reason]` +- **Dependency direction**: UI → Core → Types. Never reverse. Use forward declarations or interfaces to break upward deps. +- **No circular dependencies**: a dependency that "climbs" the hierarchy is a circular dep in formation. Resolve by forward declaration or interface extraction. + +--- + +## Error handling + +This is the single statement of the error-handling policy. Other sections point +here rather than restating it. + +- Never swallow errors silently: no empty `catch {}`, no ignored `Result`, no `_ =` +- **Rust**: `?`, `map_err()`, or `anyhow::bail!` — `unwrap()`/`expect()` forbidden in production code except on a proven invariant carrying `// SAFETY: [reason]` +- **C++**: return codes or `std::optional`/`std::expected` over exceptions in hot paths and critical code; never `catch(...) {}` +- At system boundaries (I/O, HTTP, network, user input, external parsing): always handle explicitly +- Internal trusted boundaries may `assert`/`debug_assert` in debug, panic in Rust + +--- + +## Naming & comments + +- **Language**: English everywhere — code, comments, commits, PR descriptions. One language per repo. +- **Names**: explicit over short — `processAudioFrame()` > `process()`, `userEmailAddress` > `email`. One term per concept across the codebase. +- **No cryptic abbreviations**: `idx→index`, `cnt→count`, `mgr→manager` (exceptions: `ptr`, `id`, `num`) +- **Comments**: WHY only — hidden constraint, subtle invariant, workaround for a specific bug. Never describe WHAT the code does, and never to explain confusing code: simplify the code instead. (One exception, in §Senior reflexes: public interface contracts.) +- **Dead code** — never comment it out. Remove pre-existing dead code only when + removal belongs to accepted scope; deletion safety follows + `skills/implementation-economy/SKILL.md`. `git log -S "functionName"` + recovers any deleted code. + +--- + +## Constants & resources + +- No magic numbers or strings appearing more than once → named constant +- **Rust**: `const` at module level or in `impl` block +- **C++**: `constexpr` or `static constexpr`; never bare `#define` for typed values +- **C++ resources**: no naked `new`/`delete` — `std::unique_ptr`, `std::make_unique`, RAII always. Every acquired resource is released via RAII. + +--- + +## Git & collaboration + +- Commit format: `(): ` — types: `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `chore` +- PR size: ≤400 LOC changed. Beyond: split into sequential autonomous PRs, each independently buildable +- Squash merge preferred: one clean commit per PR in main history; never merge-commit noise in `main` +- **Pre-commit** (before every non-trivial commit): + - Rust: `cargo clippy --all-targets -- -D warnings && cargo test` + - C++: `cmake --build build/ --config Release` + - TS/JS: `tsc --noEmit && eslint src/` + +--- + +## Work management + + + +- **The project's work authority is the canonical actionable backlog.** + WorkItem state, review state and completion live there — never in this + repository, never in the Vault. Skills = procedures; ADRs = accepted + architecture; Work Contracts = deterministic verification when policy + requires them; Vault = historical context only. This repository never stores + project-specific backlog state. +- **Claim before working.** A visible claim first (the provider's preferred + signal — assignment/assignee — or an explicit claim message as fallback), + then deterministic resolution: each actor stands at their earliest valid + claim event, actors ordered by `created_at` then stable identifier; losers + STOP. Journal the attempt locally before the remote signal + (`ainative claim-attempt`); an uncertain outcome is never retried + automatically. No lock service, ever. An active linked implementation + ChangeRequest is a claim-level conflict (`ACTIVE_PR_CONFLICT`): no parallel + implementation unless explicitly requested. +- **Protect Acceptance Criteria.** An implementation agent MUST NOT weaken, + remove, replace or materially reinterpret a WorkItem's Acceptance Criteria to + make its implementation pass. It may detect ambiguity, identify + infeasibility, propose a change, or ask — a material AC change becomes + authoritative only after explicit maintainer approval AND persistence in the + WorkItem. Material = required behavior, functional scope, security, + performance thresholds, platforms, failure behavior, public API/contract, + acceptance thresholds. Uncertain -> treat as material -> ask. +- **Scope is the WorkItem.** Unrelated findings never expand the current + ChangeRequest: P0/P1 -> separate WorkItem/escalation (may block a release, + not this change); P2/P3 -> backlog candidate. +- **Deferred work leaves the chat.** Significant deferred work that is + actionable and worth retaining becomes a WorkItem; explicitly rejected work + gets no WorkItem. +- **Refs while working, Closes when ready.** ChangeRequests start with a + reference to the WorkItem (`Refs #N`). The closing keyword appears only once + MERGE_READY holds, and only immediately before the merge — after one final + re-read of the WorkItem and its current AC (FINAL_MERGE_FRESHNESS). A + material difference is `ISSUE_CHANGED` -> stop. +- **MERGE_READY is not DONE.** DONE = MERGE_READY was valid + ChangeRequest + merged + WorkItem closed *as completed* (+ provider completion state = Done + when configured). +- **Policy conflicts stop work.** A local AGENTS.md may specialize or + strengthen this policy, never silently weaken it. A real contradiction is + surfaced visibly (POLICY_CONFLICT), never resolved by silent choice. + +Details, flow diagram and the skill-level procedures: the AI Native Dev Stack +repository's `docs/FORGE-WORKFLOW.md`, with the GitHub and GitLab mappings. + +## Engineering discipline + + +- One authoritative source per piece of system knowledge (DRY). When knowledge is copied, choose one owner and derive or trace the rest. +- Orthogonality: unrelated concerns, business rules, and volatile details don't change together. When changes fan out widely, restore ownership. +- Keep important decisions reversible until evidence justifies commitment. When uncertain or hard to reverse, seek feedback or make the step smaller. +- Automate repeatable work; keep automation versioned. +- Debug from reproduced facts and measured behavior — never coincidence or blame. +- Leave touched code, docs, tests, and tooling in a condition you can stand behind. + +--- + +## Clean code discipline + + +- Preserve behavior, write for the next reader, leave touched code cleaner within scope. +- Split boolean flags and mixed abstraction levels out of functions. (Naming itself: see §Naming & comments.) +- Separate commands from queries. No hidden side effects. +- When touching code: remove the smell most likely to make the next change risky or unclear. + +--- + +## Refactoring discipline + + +- Preserve observable behavior; isolate feature changes, migrations, and cleanup into separate steps. +- Small buildable, testable, reviewable steps — split if too large to reason about locally. +- Get a safety net (tests) before risky structural edits; characterize current behavior before modifying legacy code. +- Refactor the smell blocking the current change, not every smell nearby. +- When the same edit appears for a third time: centralize ownership instead of copying again. +- Stop when the change is easy, the code is clearer, and further cleanup would be speculative. + +--- + +## Design complexity + + +- Optimize for lower cognitive load — not shorter files, familiar patterns, or clever compactness. +- Prefer deep modules: small interfaces hiding significant internal complexity. Reject wrappers that don't hide real complexity. +- Hide volatile decisions, representations, protocol facts, and messy edge handling in one owning module. +- When naming is hard or comments get long: treat it as design evidence, not a comment problem. +- When one change spreads widely: look for duplicated knowledge, hidden dependencies, or the wrong owner. +- Add complexity for performance or patterns only when evidence justifies it. +- **Implementation Economy** — after accepted scope is established, use + `skills/implementation-economy/SKILL.md` for implementation-time + simplification and ownership-first mechanism selection. It never changes + accepted scope, AC, architecture authority or review criteria. + +--- + +## Codebase analysis strategy + +Before any analysis, audit, or review, estimate scope and classify intent. + +**Estimate scope** (run this first): +```bash +git ls-files | grep -E "\.(py|rs|cpp|c|h|hpp|ts|js|go|sh)$" | xargs wc -c 2>/dev/null | tail -1 +# → divide by 4 = estimated tokens (±20% heuristic) +git ls-files | grep -E "\.(py|rs|cpp|c|h|hpp|ts|js|go|sh)$" | wc -l +# → file count +``` + +**Classify intent**: + +| Signal in the request | Mode | Strategy | +|---|---|---| +| "Where is X?", "find Y" | **Lookup** | Explore sub-agent | +| "How does X work?" | **Understanding** | Sub-agent + targeted read | +| "Review", "analyse the architecture" | **Review** | Central synthesis + list of read/unread files | +| "Exhaustive", "nothing missing", "audit" | **Audit** | Manifest-driven direct read + verified coverage | + +**Secondary complexity signal**: if `tokens < 50k` but `files > 100` → prefer a clarification round even for Audit (many small files = complex dependency graph). + +**Strategy by size**: +``` +< 50 000 tokens → read ALL files directly in the main context (100% coverage guaranteed) +50k – 150k → deterministic cartography (ctags/AST) + layered reads +> 150 000 tokens → multi-phase workflow (cartography → parallel reads → synthesis) +``` + +**Verified coverage (mandatory in Audit mode)**: +1. Before starting: generate the complete file list — `git ls-files | grep -E "..."` — this is the execution contract +2. Declare legitimate exclusions upfront by path (`generated/`, `vendor/`, `build/`) +3. Read every remaining file in sequence +4. Report at the end: + +``` +Coverage — Audit [repo] +Files: 11 total | Excluded (generated): 0 | Excluded (vendor): 0 | To read: 11 +Read: 11/11 (100%) ✅ + +Unread business-logic files: none +Central unread modules (>5 incoming imports): none +``` + +**Confidence rule derived from coverage**: +- `≥ 80%` → conclusions without qualifier +- `60–80%` → prefix each conclusion with "Partial analysis:" +- `< 60%` → prefix with "⚠️ Provisional — insufficient coverage" + +**Note**: `ctags`/AST tools give structural exhaustiveness (all symbols), NOT behavioral exhaustiveness (same signature ≠ same logic). Direct read remains necessary for behavioral audits. Never use a sub-agent for Audit mode. + +--- + +### This project — ai-native-dev-stack + +Measured 2026-09-14 (v2.4.3: OpenCode plugin runtime fix, adapters in the published payload, opencode-plugin CI gate) via `git ls-files` (image excluded). Re-measure +with `python3 scripts/measure_scope.py`; CI fails when these figures drift. + +| Scope | Tokens (÷4) | Files | Strategy | +|---|---|---|---| +| Core stack (excl. anti-debt) | ~824 494 | 477 | **Layered read** — cartography first then targeted reads | +| Anti-debt agent | ~133 902 | 117 | Read its `AI_CONTEXT.md` and ADRs before its sources | +| Whole repo | ~958 397 | 594 | **Multi-phase workflow** — never a single direct read | + +Do **not** read the whole repo in one pass: at ~958k tokens it does not fit, +and the strategy table above applies in full. Pick the scope the task needs — +most work touches only one of the three halves below. + +The core stack is itself two independent halves, and almost no task needs both: + +| Half | Entry points | What it decides | +|---|---|---| +| **Distribution & lifecycle** (`ainative/`) | `docs/DISTRIBUTION-LIFECYCLE.md`, ADR-0009 | what is installed, and what may be replaced or deleted | +| **Verified Work Plane** (`ainative_workplane/`) | `docs/VERIFIED-WORK-PLANE.md`, ADR-0001…0008 | whether declared work has converged | + +The dependency runs one way: lifecycle may invoke the Work Plane, never the +reverse. Reading either half without the other is correct. + +> This block said "~22 000 tokens, 11 files, direct read always" until +> 2026-08-27, measured ten weeks and 178 files earlier. Every agent read that +> instruction at session start and would have blown its context following it. +> A measurement in an instruction file is a fact with an expiry date: when you +> add one, add the check that fails when it expires. + +--- + +## Senior engineering reflexes + +The rules above are the always-on core. The reflexes below are the full senior playbook — apply them proactively, without being asked, scaled to the project's language and risk. They are the canonical source: per-tool configs (`CLAUDE.md`, MiniMax `agent.md`, `.cursorrules`) should reference this file rather than re-state these rules. + +### Documentation & decisions + +- **ADR** (Architecture Decision Record) — documents a decision *already made*. Retrospective, in `docs/adr/NNNN-short-title.md`: Context · Decision · Rejected alternatives · Consequences. Triggers: new central pattern, lib choice, thread-model constraint, public-API change. +- **RFC** (Request for Comments) — requests feedback *before* a major change. Prospective, in `docs/rfcs/`: Motivation · Detailed proposal · Alternatives · Open questions · Review deadline. +- **`// See ADR-NNNN`** in code — when a block implements a documented decision, link it so a reader reaches the "why" without searching the docs. +- **Documentation proportional to size**: >10 source files → `CLAUDE.md`; >3,000 LOC → `ARCHITECTURE.md` (thread model, data flow, ownership, red zones); >5,000 LOC → `CONTRIBUTING.md` (conventions, how to add a module, PR checklist). +- **Domain glossary** — for any jargon-dense domain (audio, finance, medical, network, games), create `docs/glossary.md` defining terms *operationally* (precise definition + link to the implementing module + concrete in-project example). A dev without domain background introduces subtle bugs by misreading a technical term. +- **Data format versioning & migrations** — every persisted format carries an explicit version + one migration function per delta (`upgradeProjectV6toV7()`). Without migrations a refactor that changes the format makes all old files unreadable. +- **CHANGELOG.md** — on any project with releases, maintain it from Conventional Commits: `## [VERSION] - YYYY-MM-DD` with `### Added/Fixed/Changed/Removed`. + +### Testing + +- **Propose tests at service creation** — when a stateless service / pure business logic is created or extracted, proactively offer a test (don't wait to be asked). Stateless free functions are the highest-priority, easiest wins. +- **Test naming**: `Component_Scenario_ExpectedBehavior` (e.g. `ProjectReader_LoadCorruptedJson_DoesNotCrash`). +- **Three classified suites**: `*_Unit` (pre-commit + CI, zero I/O, <100ms) · `*_Integration` (CI nightly, mocked devices/files) · `*_Device`/`*_AudioDevice` (manual, real hardware). +- **Integration & golden tests** on deterministic outputs: golden (render a known output, compare checksum/RMS), replay (import → edit → undo → render → verify), session-load (load N historical projects → migrations still work). +- **Fuzz & property-based**: fuzz every parser of external data (libFuzzer / `cargo-fuzz`) — malformed input must fail cleanly, never corrupt state silently. Property-based test algorithms with math invariants (`proptest`/`quickcheck`/`rapidcheck`) — e.g. "audio output stays within [-1.0, 1.0] for any input". +- **Invariants as runtime asserts** — every critical invariant documented in ARCHITECTURE.md has a matching `assert()`/`debug_assert!()` in code. An unverified invariant is just a promise. Free in release, immediate detection in debug. +- **Zero-alloc CI check** — any real-time thread has a test asserting `heap_alloc_count == 0` after N iterations. An accidental allocation in a hot path is invisible until user reports ("crash after 2h"). + +### Concurrency & systems + +- **Ownership graph = DAG** — never an ownership cycle. Upward (child→parent) or lateral (sibling→sibling) references use `weak_ptr`/observer/callback, never a strong ref. Destruction order = reverse of construction. +- **Shutdown sequence** — in any multi-threaded system, document in ARCHITECTURE.md which thread is joined first, in what order queues drain, when OS handles are released. A service destroyed while the audio thread holds a reference = guaranteed crash. +- **Lock hierarchy** — document the mandatory acquisition order (e.g. `ProjectMutex → AudioGraphMutex → TrackMutex`). Never acquire a level-N lock while holding level-N+1. Prevents deadlocks; TSan detects violations. +- **Thread annotations** — comment every method with `// THREAD: audio | ui | any` so the model is explicit in code, not only in ARCHITECTURE.md. +- **RT threads** (audio callback, video decode) — no logging, no mutex, no I/O, no allocation. Communicate via a lock-free ring buffer: RT thread pushes `(EventId, timestamp, value)` with atomics; a low-priority thread drains to log/UI. Without it, "it crackles sometimes" reports are undebuggable. +- **Structured logging** — 4 levels (ERROR irrecoverable · WARN degraded · INFO session events · DEBUG off in release). Per-domain macros when justified (`LOG_AUDIO_WARN`). RT threads log only via the ring buffer above. + +### Safety & static analysis + +- **Error handling policy** — see §Error handling above. It is stated once, there. +- **RAII (C++)** — no naked `new`/`delete`; `make_unique`/`make_shared`/stack. FFI opaque handles wrapped in a RAII type immediately (no naked handle circulating). +- **`using namespace` banned at file scope** — in headers (0 exceptions, fully qualify) and production `.cpp` (function scope or explicit alias `namespace fs = std::filesystem;` only). +- **Sanitizers** in dedicated CI builds: ASan (use-after-free, overflow) + UBSan (signed overflow, null deref) can combine; TSan (data races) separate build; MSan (uninit reads). Rust FFI modules: `cargo miri test` (nightly) catches UB at the `extern "C"` boundary that C++ sanitizers miss. +- **Clang-Tidy (C++)** — beyond cppcheck. Priority checks: `bugprone-use-after-move`, `bugprone-dangling-handle`, `performance-unnecessary-copy-initialization`, `modernize-use-override/make-unique`, `readability-function-size`. Ship a `.clang-tidy` + run in pre-commit/CI. +- **Hardware abstraction for testability** — any service touching OS resources consumes an interface, never the hardware directly. Priority interfaces: `IFileSystem`, `IClock` (timers/autosave), `IAudioSink`. Lets CI simulate disk errors / latency without real hardware. + +### Supply chain + +- **`cargo audit --deny warnings`** (RustSec CVE scan of `Cargo.lock`) and **`cargo-deny`** (crate bans, license policy, duplicate versions) on any serious Rust project. +- **`osv-scanner --recursive .`** or **`trivy fs .`** for vendored/system C++ deps (SDL3, ImGui, FFmpeg, codecs). C++ CVEs are rarer but graver (codec overflow = RCE). Nightly CI. +- **CODEOWNERS** — `.github/CODEOWNERS` assigning ownership by domain + mandatory reviewer on frozen cores / public APIs / CI. Create it even solo: it prepares a second dev with zero ambiguity. + +### Process & collaboration + +- **Code review checklist** (before approving any PR): Correctness · Security (secret/injection/missing validation) · Thread safety (shared data protected, atomics correct) · Resources (no leak) · Performance (no alloc in hot path, no avoidable O(n²)) · Readability (a senior understands it in 30s) · Tests (logic covered / no broken test). +- **Performance budgets** — document per subsystem and check in CI: audio callback <2ms · UI frame <16.6ms (60fps) · undo/redo <50ms · project load <3s · heavy ops (scan, waveform) async non-blocking. +- **Tech debt SLA** — build/clippy warning: immediate (don't commit) · race condition: 24h · architecture violation: 7 days · legacy TODO: next sprint. "Stop-the-line" on the first two. +- **Feature flags** — isolate unfinished/experimental code behind a runtime flag (preferred, `config.json`) or compile-time `#ifdef` with `// FEATURE: ... — remove when: ...`. Do not introduce `#if 0`; use a real feature flag. Pre-existing `#if 0` cleanup follows accepted scope and Implementation Economy deletion safety. +- **Public interface contracts** (exception to "comments = WHY only") — public interface headers document non-inferable contracts in one line: `// @pre Must NOT be called from audio thread`, `// @thread-safety lock-free, MT-safe`, `// @throws never (noexcept)`. +- **FFI conventions (C++ ↔ Rust)** — the most dangerous boundary. Every `extern "C"`: return an `int32_t`/`ResultCode` error code (never implicit); complex errors via a thread-local `get_last_error_str()`; ownership documented explicitly (`Box::into_raw()` → C++ `unique_ptr` with a deleter calling back into Rust; never `free()` C++-side on Rust-allocated memory). Capture conventions in an "Interop Error Handling + Memory Ownership" ADR. +- **Scoped cleanup** — leave touched code cleaner only when the cleanup belongs + to the accepted work. Unrelated neighbouring debt does not enter the current + PR because it is quick; route it through normal triage. + +--- + +## Architectural change discipline (vs routine fixes) + +Routine fixes (lint, typo, single-line, doc, test): +- Use existing AGENTS.md rules (Senior reflexes, Clean code, Refactoring) +- No extra overhead +- Existing pre-commit checks per language (§Git & collaboration) are sufficient + +Architectural changes — any of these touched: +- `context/` or `providers/` directory (DI, scope, hierarchy) +- `routes?/` (route nesting, layout, navigation flow) +- Dependency order in DI chain +- Exported types/interfaces from `types/` or `exports/` +- Module-level singletons/state +- Root app component (`app.tsx`, `App.tsx`) + +REQUIRED before proposing the change: +1. Run `graphify path ` and cite the consumer tree in your commit message +2. Read ≥ 3 call sites of the changed symbol directly (not grep summary) +3. State scope explicitly in the commit message: + "Affects: [list]. Does not affect: [list]." +4. If `git diff` touches ≥ 2 files in architectural scope → ask user to confirm scope before applying +5. Tag the commit with `[arch-change]` for review priority + +Rationale (from 2026-06-26 incident): +The FileStoreProvider bug was introduced by a fix that placed a directory-scoped +provider inside session-scoped providers. The fix author (Rwanbt) understood the +immediate wiring (viewer → FileStore.markClean) but missed the directory-vs-session +scope distinction. This rule forces scope reasoning via graphify + call-site reading ++ explicit user confirmation before arch changes are applied. + +Enforcement: +- Portable guarantee on every harness: the manual steps above. Public + distribution supplies no automatic gate — never assume one will run. +- Mavis-only enhancement, machine-local and optional: the + `pretool-arch-change-detect` hook and the `arch-change-gate` skill exist + only in the maintainer's Mavis setup, are not public distribution, and are + not installed by `scripts/install_agents.py`. Where present they warn; they + never replace the manual evidence above. + +See also: `/projects/ai-native-dev-stack/AGENTS.md` +for project-specific context. It is not an enforcement mirror and does not +provide the Mavis hook/skill implementations. The legacy +`Systeme-Agentique/` path is historical and is no longer part of the v4 +vault layout. + +--- + +## Pre-commit checklist + +Before marking any task done: + +- [ ] Behavior preserved (or intentionally changed with tests)? +- [ ] One authoritative source per fact modified? +- [ ] Local reasoning clear without external context? +- [ ] No silent errors, no magic numbers, no dead code? +- [ ] Named accurately? Comments WHY only? +- [ ] File/function within size budget? +- [ ] Pre-commit checks pass (lint + tests)? +- [ ] PR ≤400 LOC or split planned? + +--- + +## Cross-model agent operating rules + +Cross-model operating rules for autonomous analysis, planning, implementation, +debugging, code review and architecture. Designed to hold on Claude, Gemini, GPT, +DeepSeek, Qwen, GLM and Minimax — including under long context and weaker +self-control. + +Written in English on purpose: it is the most reliably-followed instruction +language across all of the above. Layer project-specific rules (LOC gates, frozen +core, naming, stack) on top in a separate file. + +--- + +## Core principle + +Reliability, not speed, and not volume of text. + +A plausible explanation is not the goal. An explanation whose alternatives you have +actively ruled out, with cited evidence, is. + +Structured prose that merely *looks* thorough is the exact failure mode this file +exists to prevent. Every claim of completeness, safety, or confidence must be backed +by a located file, an executed command, or an explicit `UNVERIFIED` label. Nothing +else counts. + +Rigor scales with stakes (§1). Heavy process on a trivial task is waste, not +diligence. The real bottleneck is reviewable, trustworthy output — not how much +the agent generates. + +--- + +## 0. How to use this file + +- **At session start:** read this file. If `state.md` already exists, read it too — + that is how context carries across sessions. +- **Before your first action,** state the task TIER (§1) in one line. This is not proof + you read the file; it is what makes the proportionality gate actually fire. Skip it + and you default to over- or under-doing the task. +- **Tooling dependency — read this.** These rules reach full strength only with + execution + search/file tools. Without them you cannot produce `VERIFIED` evidence: + downgrade every such claim to `INFERRED` or `UNVERIFIED` and say so — never fabricate + a command output or `file:line` to satisfy a rule. If you cannot write files, keep + `state.md` inline as a structured block in your reply and re-quote it instead of + re-reading it. +- **Precedence.** Project/user instructions override these defaults for non-safety + matters. Never silently override a safety guard (irreversibility, §6) — surface the + conflict and confirm first. +- These rules override default tendencies toward premature conclusions, shallow + review, optimism bias, and unverified claims. +- Apply rules **in proportion to TIER**. Applying CRITICAL rigor to a TRIVIAL task + is itself a rule violation. + +--- + +## 1. Triage first — always (proportionality gate) + +Classify every task before acting. State the tier explicitly. If unsure between two +tiers, pick the higher one. + +**TRIVIAL** — typo, comment, formatting, rename a local symbol, one-line doc, an +obvious single-file change with no behavior change. +→ Just do it. Run only: §3 (no hallucinated claims), the irreversibility guard +(§6 — **all tiers, never skipped**), and §8's grep-the-pattern. Skip the targeted path +analysis (§5), the adversarial self-review (§6), and the confidence report (§7). + +**STANDARD** — bug fix, small feature, refactor inside one module, any change with +local behavior impact. +→ Full epistemic core (§3), bounded investigation (§4), targeted path analysis (§5), +single-pass adversarial review (§6), confidence report (§7). + +**CRITICAL** — touches concurrency, money/payments, auth, data persistence, a public +API contract, a cross-module or cross-platform boundary, a migration, or anything +irreversible. +→ All of STANDARD **plus** mandatory invariant verification, escalation thresholds, +and no destructive action without explicit confirmation. + +Never silently upgrade scope: do not turn a typo fix into a refactor. + +--- + +## 2. State file — one file, verifiable, reloaded + +Maintain **one** file: `state.md`. Not four. Create it if absent. + +Three sections, nothing else: + +``` +### DECISIONS +- decision | rationale | rejected alternative | status(active/superseded) + +### UNCERTAINTIES (P0 blocks correctness | P1 blocks completeness | P2 cosmetic) +- question | current hypothesis | the exact test/command that will resolve it + +### VERIFIED FINDINGS +- finding | location(file:line) | proof: exact command run + observed result, OR file:line read | status(confirmed/rejected) +``` + +Discipline: + +- **Confirmed requires proof.** Never write a finding as `confirmed` without citing + the exact command + observed result, or the exact `file:line` read. A static read + is `INFERRED`, never `confirmed`. +- **Reject, don't delete.** A finding shown false is marked `REJECTED` and kept. +- **Event-driven, not per-step.** Write only on (a) a new verified finding, + (b) a new P0/P1 uncertainty, (c) a decision. Do not narrate trivia into the log. +- **Cap at ~50 active entries.** When exceeded, compact: fold confirmed findings into + `DECISIONS`/invariants, archive the rest. A 5000-line log is noise you will ignore. +- **Mandatory reload.** Before any final answer, plan, or review, re-read `state.md`. + Externalizing without reloading is wasted I/O. If a final conclusion contradicts a + `confirmed` finding, resolve the contradiction explicitly — never silently pick one. +- **Single writer.** `state.md` assumes one writer. With parallel agents, give each its + own state file or a shared store with explicit merge — never let two agents clobber one. + +Use this machine-parseable form for updates (keeps weaker models disciplined): + +``` + +finding: ... | loc: path/file.rs:142 | proof: `cargo test foo` -> 0 passed, panic at :142 + +``` + +--- + +## 3. Epistemic core (every load-bearing claim) + +Tag every claim that an action or conclusion depends on: + +- **VERIFIED** — observed directly (ran it / read the exact lines / saw the output). + Cite the evidence. +- **INFERRED** — deduced from observed evidence, not directly seen. State the chain. +- **ASSUMED** — neither observed nor inferred. Must never be the *silent* sole basis of + an action. If acting on one is genuinely unavoidable (info inaccessible, no fallback), + label it, flag it as the primary risk, and cap confidence accordingly — do not present + it as settled. + +Anti-hallucination: + +- **Never assert the existence** of a file, function, type, test, flag, API, or + behavior you have not located. Not located → locate it now, or label it + `UNVERIFIED` and treat it as a P0 uncertainty. +- "Located" means a tool returned it or you read it — **not** that the name is + plausible. + +Evidence hierarchy (higher beats lower on conflict): + +``` +1. Executed result (test output, run, reproduction) +2. Source you read directly +3. Tests +4. Documentation / comments +5. Inference +6. Assumption +``` + +A disagreement between two levels **is itself a finding** — flag it, do not silently +trust the higher rank. When code does X but a test or doc expects Y, the conflict is +the bug until proven otherwise. + +--- + +## 4. Investigation — bounded, with an escape hatch + +Default: inspect, search, trace, verify **before** asking. Do not interrupt for +anything obtainable independently. + +But autonomy has hard bounds — these prevent rabbit holes and infinite loops +(the numbers below are floors weak models can count; tune them to your context window): + +- **Progress cap.** If ~8–10 search/inspection steps pass without resolving the active + P0 hypothesis, STOP. Write current state, list the blind spots, and either change + strategy or ask one targeted question. +- **Loop cap.** If 3 distinct fix attempts fail, STOP repeating. Switch strategy or + escalate. Re-running the same approach is not investigation. +- **Depth bound.** Trace dependencies up/down only until impact is nil or documented. + Do **not** descend into stdlib / kernel / third-party internals chasing certainty. + +**Tool-failure awareness:** an empty result is **not** proof of absence. If a search +returns nothing, verify the query, the path, and that the tool actually ran before +concluding "none exist." A `grep` with a wrong path returning 0 is a tool failure, +not a clean repo. + +**Escape hatch** (overrides "don't interrupt"). Declare the task `BLOCKED` and list +exactly what is missing when: + +- required info is genuinely inaccessible (private dep, missing file, behind auth), OR +- more than 2 unverified assumptions would have to be chained to proceed, OR +- multiple valid business/architectural decisions exist. + +Never hallucinate to escape a blocked state. + +--- + +## 5. Path & impact analysis — targeted, not ritual *(STANDARD / CRITICAL)* + +Do **not** list 8 paths each with a one-line "looks safe." That is theater. + +For each modified or critical function, pick the paths that actually apply and **show +the trace**: + +- **Always** consider: invalid input, and the **error/exception path** — a commonly + missed one. Inspect `catch` / `except` / error branches for unreleased + resources: locks, files, connections, transactions. +- **Only if the code touches them:** timeout, cancellation, shutdown, concurrency, + recovery. +- **UI / presentation / pure-leaf code:** these mostly don't apply — say so, move on. + +Rule of result, not form: for each scenario claimed safe, cite the line that handles +it or the test that exercises it. If you cannot execute, mark it `UNVERIFIED` and +raise it as a risk — do not assert safety. + +**Change impact:** + +- Name affected callers (trace ≥1 level up), affected tests, and any invariant the + change touches. +- **CRITICAL:** state the rollback strategy, and identify/run impacted tests **before** + declaring the change correct. + +--- + +## 6. Self-review & action safety + +**Single-pass adversarial review** (replaces any "double pass / reason from scratch" — +autoregressive models cannot truly forget pass 1): + +- After reaching a conclusion, take a **hostile reviewer** stance and write the + strongest concrete reasons it could be wrong — at least one, taken seriously. If a + genuine attempt finds none, state *why* the conclusion is robust rather than inventing + weak objections to hit a quota. +- For each, check whether collected evidence already rules it out. If not, investigate. +- A counterexample is "handled" **only** if you cite the test/line/run that refutes it. + An untested "what if input is empty?" counts for nothing. + +**Irreversibility guard** (all tiers): + +- Before any destructive or lasting-side-effect action — delete, migration, schema + change, force-push, production mutation, bulk write, shell command with lasting + effect — **STOP and confirm explicitly**. Default to a reversible path (branch, + dry-run, backup) when one exists. + +**Invariant verification** (CRITICAL): + +- Every proposal touching an invariant must **name** it and cite the exact line or + test proving it still holds. Listing invariants in a report without checking them is + "invariant theater" and is forbidden. + +--- + +## 7. Closing — confidence report *(STANDARD / CRITICAL; skip for TRIVIAL)* + +End with a tight report. **Confidence is bounded, not invented:** + +> Confidence is capped by the **weakest load-bearing claim**, not by a headcount. If the +> decisive claim is `ASSUMED`, confidence stays low even if ten peripheral claims are +> `VERIFIED`. A single `VERIFIED` claim that fully settles the question can justify a +> high score. + +``` +Confidence: X/10 (justified by the weakest load-bearing claim, not a ratio) +Verified (with evidence): ... +Inferred / Assumed: ... +Inspected (cite key lines): ... +Not inspected — why safe, or flagged as risk: ... +Open P0/P1 uncertainties: ... (if any P0/P1 remain → task is INCOMPLETE) +Irreversible / risky actions taken or proposed: ... +``` + +--- + +## 8. Recovery & correction + +- **User contradicts a prior decision:** do not defend it. Update `state.md` with the + pivot and its reason, then proceed on the new basis. +- **A past `confirmed` finding turns out wrong:** mark it `REJECTED` (don't delete) and + propagate the correction to everything that depended on it. +- **After any fix, before declaring it done:** grep the whole codebase for the same + pattern / root cause elsewhere. One instance fixed is not the class fixed. + +--- + +## Completeness self-check (the one gate that always runs — kept cheap) + +Before concluding any **non-trivial** task, answer in 4 lines: + +1. What did I inspect (with evidence)? +2. What could this impact that I did **not** inspect? +3. Why is that safe — or is it an open risk? +4. What is the single thing I'm most likely to have gotten wrong? + +If you cannot answer these, the task is not done. + +--- + +## Anti-patterns — NEVER (quick reference) + +- ❌ "This is probably safe." → verify, or label `UNVERIFIED`. +- ❌ "The file looks fine" after opening it. → cite the line, or you didn't review it. +- ❌ Marking `confirmed` from a static read. → that's `INFERRED`, not confirmed. +- ❌ "I checked for counterexamples" with no test. → ritual, not verification. +- ❌ Listing invariants without citing what proves them. → theater. +- ❌ "Scope exhausted" with no bound. → name what you didn't inspect and why. +- ❌ Empty search result → "nothing exists." → check the tool / path first. +- ❌ Heavy analysis on a typo. → wrong tier. +- ❌ "I'll remember this." → you won't. Write it to `state.md`. +- ❌ Hallucinating an API to escape a blocked state. → declare `BLOCKED` instead. diff --git a/tests/lifecycle_support.py b/tests/lifecycle_support.py index 74da725a..50e339c4 100644 --- a/tests/lifecycle_support.py +++ b/tests/lifecycle_support.py @@ -72,6 +72,9 @@ def build_distribution_tree(root: Path, version: str = "1.0.0", *, templates = root / "templates" templates.mkdir(parents=True, exist_ok=True) write_text(templates / "AI_CONTEXT_template.md", f"# context {version}\n") + # The engineering-method component installs from the distributed template, + # not from the repository's own AGENTS.md (ADR-0018). + write_text(templates / "AGENTS.md", f"# Engineering method {version}\n") skills = root / "skills" for name in ["demo-skill"] + ([extra_skill] if extra_skill else []): From 9ce8714f85af941d4965b6ec44caa407e2d8bd56 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Wed, 16 Sep 2026 19:17:40 +0200 Subject: [PATCH 07/14] feat(forge): forge detection, status observation and the doctor extension (#164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ainative forge detect|status` observe the project's Git remotes and render the Work Authority resolution. Read-only by construction: the only subprocess is `git remote`, the only inputs are the pure resolver, the install state and the claim journal — zero network, zero credentials, zero writes, zero persistent trust. A fork renders as AMBIGUOUS (both candidates shown, no preference for origin) and an unknown host as UNAVAILABLE, both exit 0: detection is a diagnostic, the refusal belongs to the mutation path. - ainative/observation.py: remote reading, `forge_picture()` (resolution as state), `project_view()` (features + forge + unresolved claim attempts) and the doctor text lines. An unreadable claim journal is displayed, not a crash. - ainative/forge_cli.py: the command surface; `status` adds the effective feature set (shared projection) and the unresolved claim count. - Doctor extension: profile/features/forge/claim summary in both outputs, never a credential (`doctor --json` gains features, forge, claim_attempts). - status.py: reports the effective feature set through the same projection (`features`, `features_projected_from_legacy`), closing the read-only parity requirement for `status`. - ainative/cli_support.py: the small shared plumbing (emit/project/report/ plan text) extracted so cli.py stays the dispatcher under its LOC budget and the command modules stop importing render callables through cli.py. - Tests: 7 new observation tests (resolution, fork rendering, unknown host, features+claims in status, byte-identical project across detect/status/ doctor) and a status/projection parity test for a V1 state. Verified: 309 lifecycle tests OK, 139 CLI/knowledge/machine suites OK, gates green (conventions, scope, complexity, LOC: cli.py 757). Refs #164 --- ainative/claim_cli.py | 12 +-- ainative/cli.py | 84 +++++-------------- ainative/cli_support.py | 74 +++++++++++++++++ ainative/forge_cli.py | 85 +++++++++++++++++++ ainative/lifecycle/feature_cli.py | 11 ++- ainative/lifecycle/status.py | 18 ++++ ainative/observation.py | 131 ++++++++++++++++++++++++++++++ tests/test_forge_claims.py | 67 +++++++++++++++ tests/test_lifecycle_features.py | 11 +++ 9 files changed, 418 insertions(+), 75 deletions(-) create mode 100644 ainative/cli_support.py create mode 100644 ainative/forge_cli.py create mode 100644 ainative/observation.py diff --git a/ainative/claim_cli.py b/ainative/claim_cli.py index a5ec065c..8f270550 100644 --- a/ainative/claim_cli.py +++ b/ainative/claim_cli.py @@ -11,9 +11,9 @@ import argparse from pathlib import Path -from typing import Callable from . import claims +from .cli_support import project_from, report as _report def add_claim_parser(commands) -> None: @@ -54,8 +54,8 @@ def _attempt_text(attempt: claims.ClaimAttempt) -> str: return "\n".join(lines) -def run_claim_command(args: argparse.Namespace, *, project: Path, - report: Callable[[dict, str], int]) -> int: +def run_claim_command(args: argparse.Namespace) -> int: + project = project_from(args) if args.claim_command == "list": attempts = claims.list_attempts(project) record = {"attempts": [attempt.to_record() for attempt in attempts], @@ -65,12 +65,12 @@ def run_claim_command(args: argparse.Namespace, *, project: Path, for attempt in attempts: lines.append(f" {attempt.state:<10} {attempt.attempt_id} " f"item {attempt.item}") - return report(record, "\n".join(lines)) + return _report(args, record, "\n".join(lines)) if args.claim_command == "inspect": attempt = claims.load_attempt(project, args.attempt_id) - return report(attempt.to_record(), _attempt_text(attempt)) + return _report(args, attempt.to_record(), _attempt_text(attempt)) attempt = claims.abandon(project, args.attempt_id, confirm=args.confirm) - return report(attempt.to_record(), _attempt_text(attempt)) + return _report(args, attempt.to_record(), _attempt_text(attempt)) __all__ = ["add_claim_parser", "run_claim_command"] diff --git a/ainative/cli.py b/ainative/cli.py index 0f0eccac..0f6accf6 100644 --- a/ainative/cli.py +++ b/ainative/cli.py @@ -14,12 +14,14 @@ from __future__ import annotations import argparse -import json import os import sys from pathlib import Path from .lifecycle.errors import EXIT_FAILED, EXIT_INVALID_REQUEST, EXIT_OK, LifecycleError +from .cli_support import (ACTION_LABELS, action_label as _action_label, # noqa: F401 + emit as _emit, plan_text as _plan_text, + project_from as _project, report as _report) # Routed to the Verified Work Plane, unchanged. Their exit codes and output are # the Work Plane's contract and are not reinterpreted here. @@ -37,14 +39,6 @@ """ -def _emit(payload: object) -> None: - print(json.dumps(payload, indent=2, sort_keys=True, default=str)) - - -def _project(args: argparse.Namespace) -> Path: - return Path(getattr(args, "project", None) or Path.cwd()) - - def _add_common(parser: argparse.ArgumentParser, *, dry_run: bool = True, yes: bool = False) -> None: parser.add_argument("--project", type=Path, default=None, @@ -178,10 +172,12 @@ def build_parser() -> argparse.ArgumentParser: from ainative.knowledge.cli import add_context_parser, add_knowledge_parser from ainative.lifecycle.feature_cli import add_feature_parser from ainative.claim_cli import add_claim_parser + from ainative.forge_cli import add_forge_parser add_knowledge_parser(commands) add_context_parser(commands) add_feature_parser(commands) add_claim_parser(commands) + add_forge_parser(commands) for name in VERIFIED_COMMANDS: commands.add_parser(name, add_help=False, @@ -228,55 +224,6 @@ def _choose_profile(args: argparse.Namespace) -> str: "Use `ainative init --profile standard` or `--profile verified`.") -def _report(args: argparse.Namespace, record: dict, text: str) -> int: - if getattr(args, "json", False): - _emit(record) - else: - print(text) - return EXIT_OK - - -# What the plan says, as opposed to what the journal records. `BLOCK_WRITE` -# read to a user as "blocked" while the operation actually proceeded; the -# internal names stay stable for journals, the labels must not lie. -ACTION_LABELS = { - "CREATE": "create", - "REPLACE": "replace", - "REMOVE": "remove", - "SKIP": "skip", - "PRESERVE": "preserve", - "CONFLICT": "conflict", - "REGION_WRITE": "configure-region", - "REGION_REMOVE": "remove-region", - "HOOK_WRITE": "configure-hook", - "HOOK_REMOVE": "remove-hook", - "BLOCK_WRITE": "configure-region", - "BLOCK_REMOVE": "remove-region", -} - - -def _action_label(action: str) -> str: - return ACTION_LABELS.get(action, action.lower()) - - -def _plan_text(result) -> str: - plan = result.plan - header = "(dry-run — nothing was written)\n" if result.dry_run else "" - counts = ", ".join(f"{_action_label(action)} {count}" - for action, count in sorted(plan.counts().items())) or "no changes" - lines = [f"{header}{plan.operation}: {plan.from_profile or 'none'} -> " - f"{plan.to_profile or 'none'}", f" {counts}"] - for change in plan.changes: - if change.action in ("SKIP",) and not result.dry_run: - continue - lines.append(f" {_action_label(change.action):<18} {change.path}" - + (f" ({change.reason})" if change.reason else "")) - for notice in result.notices: - lines.append("") - lines.append(notice) - return "\n".join(lines) - - def _cmd_init(args: argparse.Namespace) -> int: from .lifecycle import installer @@ -313,16 +260,19 @@ def _cmd_profile(args: argparse.Namespace) -> int: def _cmd_feature(args: argparse.Namespace) -> int: from .lifecycle.feature_cli import run_feature_command - return run_feature_command(args, project=_project(args), - report=lambda record, text: _report(args, record, text), - plan_text=_plan_text) + return run_feature_command(args) def _cmd_claim_attempt(args: argparse.Namespace) -> int: from .claim_cli import run_claim_command - return run_claim_command(args, project=_project(args), - report=lambda record, text: _report(args, record, text)) + return run_claim_command(args) + + +def _cmd_forge(args: argparse.Namespace) -> int: + from .forge_cli import run_forge_command + + return run_forge_command(args) def _confirm_purge(args: argparse.Namespace, project: Path) -> bool: @@ -389,16 +339,21 @@ def _doctor_collect(project: Path, check_updates: bool): def _cmd_doctor(args: argparse.Namespace) -> int: + from . import observation from .lifecycle import environment, recovery, updater from .knowledge import doctor as knowledgedoctor project = _project(args) diagnosis, knowledge, checks, healthy = _doctor_collect( project, args.check_updates) + view = observation.project_view(project) if args.json: record = diagnosis.to_record() record["knowledge"] = knowledge record["environment"] = checks + record["features"] = view["features"] + record["forge"] = view["forge"] + record["claim_attempts"] = view["claim_attempts"] _emit(record) else: print(f"Project: {diagnosis.project}") @@ -417,6 +372,8 @@ def _cmd_doctor(args: argparse.Namespace) -> int: # (#131 / AUD-202). print("Updates") print(f" {updater.notice_line(diagnosis.update)}") + for line in observation.doctor_lines(view): + print(line) print("Knowledge:") print(f" status: {knowledge['status']} store: {knowledge['store']}") if knowledge["status"] != knowledgedoctor.STATUS_FAIL: @@ -721,6 +678,7 @@ def _cmd_setup(args: argparse.Namespace) -> int: "profile": _cmd_profile, "feature": _cmd_feature, "claim-attempt": _cmd_claim_attempt, + "forge": _cmd_forge, "status": _cmd_status, "doctor": _cmd_doctor, "repair": _cmd_repair, diff --git a/ainative/cli_support.py b/ainative/cli_support.py new file mode 100644 index 00000000..7dafe412 --- /dev/null +++ b/ainative/cli_support.py @@ -0,0 +1,74 @@ +"""Small shared plumbing for the `ainative` command modules. + +Split out so `cli.py` stays the dispatcher and the command modules +(`feature_cli`, `claim_cli`, `forge_cli`) render the same way without +importing `cli` back — which would be a cycle. Nothing here imports the +lifecycle or the Work Plane: it is argparse, JSON and a path. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from .lifecycle.errors import EXIT_OK + +# What the plan says, as opposed to what the journal records. `BLOCK_WRITE` +# read to a user as "blocked" while the operation actually proceeded; the +# internal names stay stable for journals, the labels must not lie. +ACTION_LABELS = { + "CREATE": "create", + "REPLACE": "replace", + "REMOVE": "remove", + "SKIP": "skip", + "PRESERVE": "preserve", + "CONFLICT": "conflict", + "REGION_WRITE": "configure-region", + "REGION_REMOVE": "remove-region", + "HOOK_WRITE": "configure-hook", + "HOOK_REMOVE": "remove-hook", + "BLOCK_WRITE": "configure-region", + "BLOCK_REMOVE": "remove-region", +} + + +def emit(payload: object) -> None: + print(json.dumps(payload, indent=2, sort_keys=True, default=str)) + + +def project_from(args: argparse.Namespace) -> Path: + return Path(getattr(args, "project", None) or Path.cwd()) + + +def action_label(action: str) -> str: + return ACTION_LABELS.get(action, action.lower()) + + +def report(args: argparse.Namespace, record: dict, text: str) -> int: + if getattr(args, "json", False): + emit(record) + else: + print(text) + return EXIT_OK + + +def plan_text(result) -> str: + plan = result.plan + header = "(dry-run — nothing was written)\n" if result.dry_run else "" + counts = ", ".join(f"{action_label(action)} {count}" + for action, count in sorted(plan.counts().items())) or "no changes" + lines = [f"{header}{plan.operation}: {plan.from_profile or 'none'} -> " + f"{plan.to_profile or 'none'}", f" {counts}"] + for change in plan.changes: + if change.action in ("SKIP",) and not result.dry_run: + continue + lines.append(f" {action_label(change.action):<18} {change.path}" + + (f" ({change.reason})" if change.reason else "")) + for notice in result.notices: + lines.append("") + lines.append(notice) + return "\n".join(lines) + + +__all__ = ["ACTION_LABELS", "emit", "project_from", "action_label", "report", "plan_text"] diff --git a/ainative/forge_cli.py b/ainative/forge_cli.py new file mode 100644 index 00000000..df503b75 --- /dev/null +++ b/ainative/forge_cli.py @@ -0,0 +1,85 @@ +"""`ainative forge detect|status` — observation only. + +Zero network, zero credentials, zero writes, zero persistent trust (PR-3, +#164). `detect` answers "what can be seen locally"; `status` adds the effective +feature set and the unresolved claim attempts. A fork renders as AMBIGUOUS and +nothing here prefers origin — the rendering is the diagnostic, the refusal +belongs to the mutation path (ADR-0018). +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from . import observation +from .cli_support import project_from, report as _report + + +def add_forge_parser(commands) -> None: + forge = commands.add_parser( + "forge", help="Observe the project's remotes and work authority.") + subcommands = forge.add_subparsers(dest="forge_command", required=True) + detect = subcommands.add_parser( + "detect", help="Observed remotes and the Work Authority resolution.") + _common(detect) + status = subcommands.add_parser( + "status", help="The forge picture: remotes, resolution, features, claims.") + _common(status) + + +def _common(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--project", type=Path, default=None, + help="project root (default: the current directory)") + parser.add_argument("--json", action="store_true", help="machine-readable output") + + +def _resolution_line(resolution: dict) -> str: + authority = resolution.get("authority") or {} + if authority: + return (f"Resolution: {resolution['state']} " + f"{authority.get('provider')}:{authority.get('project')}") + return f"Resolution: {resolution['state']}" + + +def detect_text(picture: dict) -> str: + lines = [f"Git repository: {'yes' if picture['git'] else 'no'}"] + if picture["remotes"]: + lines.append("Work authority (observed candidates):") + for remote in picture["remotes"]: + lines.append(f" {remote['name']:<10} {remote['provider']:<8} " + f"{remote['project'] or '-':<24} {remote['url']}") + else: + lines.append("Work authority (observed candidates): none") + lines.append(_resolution_line(picture["resolution"])) + if picture["resolution"].get("detail"): + lines.append(f" {picture['resolution']['detail']}") + return "\n".join(lines) + + +def status_text(view: dict) -> str: + features = view["features"] + summary = view["claim_attempts"] + lines = ["Active features: " + (", ".join(features["active"]) or "none") + + (" (projected from the V1 state)" if features["projected_from_legacy"] + else "")] + if summary["journal"] == "ok": + lines.append(f"Unresolved claim attempts: {len(summary['unresolved'])}") + else: + lines.append(f"Unresolved claim attempts: journal unavailable " + f"({summary['detail']})") + lines.append("") + lines.append(detect_text(view["forge"])) + return "\n".join(lines) + + +def run_forge_command(args: argparse.Namespace) -> int: + project = project_from(args) + if args.forge_command == "detect": + picture = observation.forge_picture(project) + return _report(args, picture, detect_text(picture)) + view = observation.project_view(project) + return _report(args, view, status_text(view)) + + +__all__ = ["add_forge_parser", "run_forge_command", "detect_text", "status_text"] diff --git a/ainative/lifecycle/feature_cli.py b/ainative/lifecycle/feature_cli.py index 5b7c19f9..5c36b1fd 100644 --- a/ainative/lifecycle/feature_cli.py +++ b/ainative/lifecycle/feature_cli.py @@ -10,8 +10,8 @@ import argparse from pathlib import Path -from typing import Callable +from ..cli_support import plan_text as _plan_text, project_from, report as _report from . import features as featureslib from . import installer from . import manifest as manifestlib @@ -75,12 +75,11 @@ def status_record(project: Path) -> tuple[dict, str]: return record, "\n".join(lines) -def run_feature_command(args: argparse.Namespace, *, project: Path, - report: Callable[[dict, str], int], - plan_text: Callable[[object], str]) -> int: +def run_feature_command(args: argparse.Namespace) -> int: + project = project_from(args) if args.feature_command == "status": record, text = status_record(project) - return report(record, text) + return _report(args, record, text) transition = {"enable": None, "disable": None, "switch_to": None} if args.feature_command == "enable": @@ -91,7 +90,7 @@ def run_feature_command(args: argparse.Namespace, *, project: Path, transition["switch_to"] = args.target result = installer.set_features(project, dry_run=args.dry_run, force_unlock=args.force_unlock, **transition) - return report(result.to_record(), plan_text(result)) + return _report(args, result.to_record(), _plan_text(result)) __all__ = ["add_feature_parser", "run_feature_command", "status_record"] diff --git a/ainative/lifecycle/status.py b/ainative/lifecycle/status.py index 3442be31..c2761b64 100644 --- a/ainative/lifecycle/status.py +++ b/ainative/lifecycle/status.py @@ -28,6 +28,8 @@ class Status: active_profile: str | None previous_profile: str | None components: list[dict] = field(default_factory=list) + features: list[str] = field(default_factory=list) + features_projected: bool = False healthy: bool = True lifecycle_notes: list[str] = field(default_factory=list) verified: dict = field(default_factory=dict) @@ -42,6 +44,8 @@ def to_record(self) -> dict: "profile": self.active_profile, "previous_profile": self.previous_profile, "components": self.components, + "features": self.features, + "features_projected_from_legacy": self.features_projected, "lifecycle": {"healthy": self.healthy, "notes": list(self.lifecycle_notes), "findings": self.counts}, "verified": self.verified, @@ -55,6 +59,12 @@ def render(self) -> str: return "\n".join(lines) lines.append("Profile") lines.append(f" {self.active_profile}") + if self.installed: + lines.append("") + lines.append("Features") + lines.append(f" {', '.join(self.features) or 'none'}" + + (" (projected from the V1 state)" + if self.features_projected else "")) lines.append("") lines.append("Components") for item in self.components: @@ -158,10 +168,18 @@ def build(project: Path, *, distribution: Distribution | None = None, if diagnosis.lock and diagnosis.lock.get("stale_suspect"): notes.append("a lifecycle lock is present and may be stale") + from . import features as featureslib + + # One projection for every reader (ADR-0017 section 4): `status` reports + # the effective feature set, exactly as `feature status` does. + effective = featureslib.project_install_state(state, distribution) + return Status( project=project, installed=True, stack_version=state.stack_version, active_profile=state.active_profile, previous_profile=state.previous_profile, components=_component_rows(distribution, state, diagnosis), + features=list(effective.active_features) if effective else [], + features_projected=bool(effective and effective.projected_from_legacy), healthy=diagnosis.healthy, lifecycle_notes=notes, verified=_verified_section(project, distribution, state), update=updaterlib.cached_notice(project, allow_network=check_updates, diff --git a/ainative/observation.py b/ainative/observation.py new file mode 100644 index 00000000..2878a5e5 --- /dev/null +++ b/ainative/observation.py @@ -0,0 +1,131 @@ +"""Locally observed forge facts: remotes, and what they support. + +Read-only by construction: the only subprocess is `git remote`, the only inputs +are the pure resolver, the install state and the claim journal. No network, no +credentials, no writes, no persistent trust (PR-3, #164). Diagnostic only — an +ambiguity is rendered, never resolved by preference. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +from . import claims +from . import forge as forgelib +from .lifecycle import environment +from .lifecycle import features as featureslib +from .lifecycle import manifest as manifestlib +from .lifecycle import state as statelib +from .lifecycle.errors import LifecycleError + +RESOLVED = "RESOLVED" +AMBIGUOUS = "AMBIGUOUS" +UNAVAILABLE = "UNAVAILABLE" +MISMATCH = "MISMATCH" + +_RESOLUTION_STATES = { + "WORK_AUTHORITY_AMBIGUOUS": AMBIGUOUS, + "WORK_AUTHORITY_UNAVAILABLE": UNAVAILABLE, + "WORK_AUTHORITY_MISMATCH": MISMATCH, +} + + +def read_remotes(project: Path) -> tuple[forgelib.ObservedRemote, ...]: + """The project's Git remotes, or () when there are none / it is not a repo.""" + + names = _git(project, "remote") + if not names: + return () + remotes = [] + for name in names.splitlines(): + name = name.strip() + if not name: + continue + url = _git(project, "remote", "get-url", name) + if url: + remotes.append(forgelib.ObservedRemote(name=name, url=url.strip())) + return tuple(remotes) + + +def _git(project: Path, *arguments: str) -> str | None: + try: + completed = subprocess.run(["git", "-C", str(project), *arguments], + capture_output=True, text=True, timeout=10) + except (OSError, subprocess.SubprocessError): + return None + if completed.returncode != 0: + return None + return completed.stdout.strip() + + +def forge_picture(project: Path, *, explicit=None, declared=None) -> dict: + """Observed remotes plus the resolver's answer, as a renderable state.""" + + observed = read_remotes(project) + record = {"git": environment.git_root(project) is not None, + "remotes": [remote.to_record() for remote in observed], + "resolution": {"state": UNAVAILABLE, "authority": None, "detail": ""}} + try: + authority = forgelib.resolve_observed_work_authority( + explicit=explicit, declared=declared, remotes=observed) + except LifecycleError as refusal: + record["resolution"]["state"] = _RESOLUTION_STATES.get(refusal.code, UNAVAILABLE) + record["resolution"]["detail"] = refusal.message + return record + record["resolution"] = {"state": RESOLVED, "authority": authority.to_record(), + "detail": ""} + return record + + +def project_view(project: Path) -> dict: + """What `forge status` and doctor display: features, forge, claim attempts.""" + + distribution = manifestlib.load() + effective = featureslib.project_install_state(statelib.load(project), distribution) + return { + "features": {"active": list(effective.active_features) if effective else [], + "projected_from_legacy": bool( + effective and effective.projected_from_legacy)}, + "forge": forge_picture(project), + "claim_attempts": _claim_summary(project), + } + + +def doctor_lines(view: dict) -> list[str]: + """The doctor extension as text lines (PR-3 / #164). Never a credential.""" + + features = view["features"] + resolution = view["forge"]["resolution"] + authority = resolution.get("authority") or {} + summary = view["claim_attempts"] + lines = [ + "Features", + " active: " + (", ".join(features["active"]) or "none") + + (" (projected from the V1 state)" if features["projected_from_legacy"] else ""), + "Work authority (observed)", + " state: " + resolution["state"] + + (f" {authority.get('provider')}:{authority.get('project')}" if authority else ""), + ] + for remote in view["forge"]["remotes"]: + lines.append(f" {remote['name']:<10} {remote['provider']:<8} " + f"{remote['project'] or '-'}") + if summary["journal"] == "ok": + lines.append(f"Claim attempts: {len(summary['unresolved'])} unresolved") + else: + lines.append(f"Claim attempts: journal unavailable ({summary['detail']})") + return lines + + +def _claim_summary(project: Path) -> dict: + try: + pending = [attempt.to_record() for attempt in claims.unresolved(project)] + except LifecycleError as error: + # An unreadable journal is a fact to display, not a crash: doctor must + # keep diagnosing the rest, and the refusal is visible in the output. + return {"journal": "unavailable", "unresolved": [], "detail": error.message} + return {"journal": "ok", "unresolved": pending, "detail": ""} + + +__all__ = ["RESOLVED", "AMBIGUOUS", "UNAVAILABLE", "MISMATCH", + "read_remotes", "forge_picture", "project_view", "doctor_lines"] diff --git a/tests/test_forge_claims.py b/tests/test_forge_claims.py index cde5c3bc..87c75afd 100644 --- a/tests/test_forge_claims.py +++ b/tests/test_forge_claims.py @@ -11,6 +11,7 @@ from __future__ import annotations import json +import subprocess import unittest from pathlib import Path @@ -276,5 +277,71 @@ def test_the_cli_lists_inspects_and_abandons(self): ["unresolved"], []) +class ForgeObservation(LifecycleTestCase): + """`forge detect|status` and the doctor extension: observe, never mutate.""" + + def snapshot(self) -> dict: + from ainative.lifecycle.digest import digest_file + + return {path.relative_to(self.project).as_posix(): digest_file(path) or "" + for path in self.project.rglob("*") if path.is_file()} + + def remote(self, name: str, url: str) -> None: + subprocess.run(["git", "-C", str(self.project), "remote", "add", name, url], + check=True, capture_output=True) + + def test_detect_resolves_a_single_hosted_remote(self): + self.remote("origin", "git@github.com:o/r.git") + record = json.loads(self.cli("forge", "detect", "--json").stdout) + self.assertEqual(record["resolution"]["state"], "RESOLVED") + self.assertEqual(record["resolution"]["authority"], + {"provider": "github", "project": "o/r"}) + + def test_a_fork_renders_ambiguous_without_preferring_origin(self): + self.remote("origin", "https://github.com/me/fork.git") + self.remote("upstream", "https://github.com/org/project.git") + completed = self.cli("forge", "detect", "--json") + self.assertEqual(completed.returncode, 0, "detection is diagnostic, not a failure") + record = json.loads(completed.stdout) + self.assertEqual(record["resolution"]["state"], "AMBIGUOUS") + self.assertEqual([item["name"] for item in record["remotes"]], + ["origin", "upstream"]) + self.assertIn("Resolution: AMBIGUOUS", self.cli("forge", "detect").stdout) + + def test_an_unknown_host_is_unavailable_not_guessed(self): + self.remote("origin", "https://git.example.com/o/r.git") + record = json.loads(self.cli("forge", "detect", "--json").stdout) + self.assertEqual(record["remotes"][0]["provider"], "unknown") + self.assertEqual(record["resolution"]["state"], "UNAVAILABLE") + + def test_forge_status_reports_features_and_claim_attempts(self): + self.install("standard") + claims.begin(self.project, claims.new_attempt( + authority=forgelib.WorkAuthorityRef(provider="github", project="o/r"), + item="42", principal=claims.principal("github", "alice"))) + record = json.loads(self.cli("forge", "status", "--json").stdout) + self.assertEqual(record["features"]["active"], ["forge-github"]) + self.assertEqual(len(record["claim_attempts"]["unresolved"]), 1) + + def test_observation_commands_never_write(self): + self.install("standard") + self.remote("origin", "git@github.com:o/r.git") + before = self.snapshot() + self.cli("forge", "detect") + self.cli("forge", "status") + self.cli("doctor") + self.assertEqual(self.snapshot(), before, + "an observation command touched the project") + + def test_doctor_reports_features_forge_and_claims(self): + self.install("standard") + self.remote("origin", "git@gitlab.com:g/p.git") + record = json.loads(self.cli("doctor", "--json").stdout) + self.assertEqual(record["features"]["active"], ["forge-github"]) + self.assertEqual(record["forge"]["resolution"]["authority"]["provider"], "gitlab") + self.assertEqual(record["claim_attempts"]["journal"], "ok") + self.assertEqual(record["claim_attempts"]["unresolved"], []) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_lifecycle_features.py b/tests/test_lifecycle_features.py index 2adfef9a..742f9957 100644 --- a/tests/test_lifecycle_features.py +++ b/tests/test_lifecycle_features.py @@ -437,5 +437,16 @@ def test_the_cli_switches_and_reports(self): self.assertEqual(self.features_now(), [GITLAB]) + def test_status_reports_the_same_effective_features_as_the_projection(self): + path = statelib.state_path(self.project) + record = json.loads(path.read_text(encoding="utf-8")) + record["schema_version"] = 1 + record.pop("active_features", None) + statelib.write_atomic(path, json.dumps(record, indent=2, sort_keys=True) + "\n") + report = json.loads(self.cli("status", "--json").stdout) + self.assertEqual(report["features"], [LEGACY]) + self.assertTrue(report["features_projected_from_legacy"]) + + if __name__ == "__main__": unittest.main() From 01b46eced94015070e120a0c1c18ab9cf07cd122 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Wed, 16 Sep 2026 20:38:38 +0200 Subject: [PATCH 08/14] feat(release): one source resolver with fail-closed selector conflicts (#165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-4A. `resolve_release_source()` (ainative/lifecycle/release_source.py) is now the only function that decides where a release comes from, and `update`, `update check`, `status` and `doctor` all consume it — so the answer cannot differ between commands (ADR-0019 sections 1-3). - Selector rules exactly as frozen: local pair -> mirror; LOCAL_DIR without provider=local -> UPDATE_SOURCE_CONFLICT; URL mixed with any selector -> UPDATE_SOURCE_CONFLICT; URL alone -> anonymous; named provider -> machine config or built-in; nothing -> machine default_provider, else GitHub.com. Validation before precedence: nothing is ordered silently, and the old "unknown provider" refusal is preserved. - Machine scope `~/.ai-native/release-providers.json` (schema 1): reserved names (github/gitlab/local) cannot be redefined, a named provider is anonymous in V1 (a custom auth_origin is refused), a future schema refuses rather than guessing. `default_provider: local` requires a configured directory. New refusal codes: UPDATE_SOURCE_CONFLICT, RELEASE_CONFIG_INVALID. - `provider.build()` delegates to the resolver; the selector constants are re-exported so existing imports keep working. - Observability: `status` and `doctor` display the effective source, the selection reason, authenticated yes/no and the auth origin — never a secret (`describe()` turns a selector conflict into a displayed state, so the diagnostics keep diagnosing while the updater refuses). - Tests: 19 in tests/test_release_source.py (selector matrix, machine config, provider construction, status/doctor parity); CI runs them in the lifecycle job. Verified: 309 lifecycle tests OK, 123 update/CLI suites OK, gates green (conventions, scope, complexity 0 findings after splitting the resolver, LOC 0 warnings). Refs #165 --- .github/workflows/ci.yml | 3 + ainative/cli.py | 5 +- ainative/lifecycle/errors.py | 2 + ainative/lifecycle/provider.py | 29 +-- ainative/lifecycle/release_source.py | 252 +++++++++++++++++++++++++++ ainative/lifecycle/status.py | 8 + tests/test_release_source.py | 199 +++++++++++++++++++++ 7 files changed, 483 insertions(+), 15 deletions(-) create mode 100644 ainative/lifecycle/release_source.py create mode 100644 tests/test_release_source.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30181fe1..d2194b86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -207,6 +207,9 @@ jobs: - name: Work authority, claim grammar and the claim journal run: python -m unittest tests.test_forge_claims -v + - name: Release source resolution and diagnostics + run: python -m unittest tests.test_release_source -v + - name: Path traversal, link escape, tampered state, archive safety run: python -m unittest tests.test_lifecycle_security -v diff --git a/ainative/cli.py b/ainative/cli.py index 0f6accf6..bad5bb94 100644 --- a/ainative/cli.py +++ b/ainative/cli.py @@ -340,7 +340,7 @@ def _doctor_collect(project: Path, check_updates: bool): def _cmd_doctor(args: argparse.Namespace) -> int: from . import observation - from .lifecycle import environment, recovery, updater + from .lifecycle import environment, recovery, release_source, updater from .knowledge import doctor as knowledgedoctor project = _project(args) @@ -354,6 +354,7 @@ def _cmd_doctor(args: argparse.Namespace) -> int: record["features"] = view["features"] record["forge"] = view["forge"] record["claim_attempts"] = view["claim_attempts"] + record["release_source"] = release_source.describe() _emit(record) else: print(f"Project: {diagnosis.project}") @@ -372,6 +373,8 @@ def _cmd_doctor(args: argparse.Namespace) -> int: # (#131 / AUD-202). print("Updates") print(f" {updater.notice_line(diagnosis.update)}") + for line in release_source.describe_lines(): + print(line) for line in observation.doctor_lines(view): print(line) print("Knowledge:") diff --git a/ainative/lifecycle/errors.py b/ainative/lifecycle/errors.py index 0c5c3464..0ebb233e 100644 --- a/ainative/lifecycle/errors.py +++ b/ainative/lifecycle/errors.py @@ -34,6 +34,8 @@ "LOCK_HELD": EXIT_FAILED, "UPDATE_UNAVAILABLE": EXIT_FAILED, "UPDATE_CHECK_FAILED": EXIT_FAILED, + "UPDATE_SOURCE_CONFLICT": EXIT_INVALID_REQUEST, + "RELEASE_CONFIG_INVALID": EXIT_INVALID_REQUEST, "UPDATE_INTEGRITY_FAILED": EXIT_FAILED, "UPDATE_INTEGRITY_METADATA_MISSING": EXIT_FAILED, "UPDATE_VERSION_MISMATCH": EXIT_FAILED, diff --git a/ainative/lifecycle/provider.py b/ainative/lifecycle/provider.py index f77ee3dd..e4431a50 100644 --- a/ainative/lifecycle/provider.py +++ b/ainative/lifecycle/provider.py @@ -30,6 +30,9 @@ from . import version as versionlib from .digest import digest_bytes from .errors import LifecycleError +# The selector names live with their owner (release_source); re-exported here +# because they have always been importable through the provider module. +from .release_source import LOCAL_SOURCE_ENV, PROVIDER_ENV, RELEASE_URL_ENV def _update_token() -> str: @@ -42,9 +45,6 @@ def _update_token() -> str: return "" DEFAULT_RELEASE_URL = "https://api.github.com/repos/Rwanbt/ai-native-dev-stack/releases/latest" -PROVIDER_ENV = "AINATIVE_UPDATE_PROVIDER" # "github" (default) | "local" -LOCAL_SOURCE_ENV = "AINATIVE_UPDATE_LOCAL_DIR" -RELEASE_URL_ENV = "AINATIVE_UPDATE_URL" # Authenticated checks, when the environment provides a token. Never logged, # never persisted: NAT-shared users hit the anonymous rate limit otherwise. TOKEN_ENVS = ("GITHUB_TOKEN", "GH_TOKEN") @@ -419,18 +419,19 @@ def verify_archive(payload: bytes, expected: str | None) -> str: def build(channel: str = "stable") -> UpdateProvider: - """The provider this environment selects. One variable, no discovery.""" + """The provider this environment selects. One resolver, no discovery. - selected = (os.environ.get(PROVIDER_ENV) or "").strip().lower() - if selected == "local": - root = os.environ.get(LOCAL_SOURCE_ENV) - if not root: - raise LifecycleError("UPDATE_CHECK_FAILED", - f"{PROVIDER_ENV}=local requires {LOCAL_SOURCE_ENV}") - return LocalDirectoryProvider(Path(root)) - if selected in ("", "github", "release-api"): - return ReleaseApiProvider() - raise LifecycleError("UPDATE_CHECK_FAILED", f"unknown update provider {selected!r}") + The decision lives in `release_source.resolve_release_source()` so + `update`, `update check`, `status` and `doctor` cannot disagree about it + (ADR-0019 section 1); this function only constructs the selected provider. + """ + + from . import release_source as release_sourcelib + + source = release_sourcelib.resolve_release_source() + if source.kind == release_sourcelib.KIND_LOCAL: + return LocalDirectoryProvider(source.directory) + return ReleaseApiProvider(url=source.metadata_url, endpoint=source.endpoint) def copy_tree(source: Path, destination: Path) -> None: diff --git a/ainative/lifecycle/release_source.py b/ainative/lifecycle/release_source.py new file mode 100644 index 00000000..c78fd2b4 --- /dev/null +++ b/ainative/lifecycle/release_source.py @@ -0,0 +1,252 @@ +"""Where a release comes from: one resolver, one conflict policy, no guessing. + +`resolve_release_source()` is the only function that decides. `update`, +`update check`, `status` and `doctor` all consume it, so the answer cannot +differ between commands (ADR-0019 sections 1–3). Validation happens before +precedence: contradictory selectors are refused, never ordered, and nothing +falls back silently. + +Selectors (environment): + +```text +AINATIVE_UPDATE_PROVIDER=local + AINATIVE_UPDATE_LOCAL_DIR -> local mirror +AINATIVE_UPDATE_LOCAL_DIR without provider=local -> UPDATE_SOURCE_CONFLICT +AINATIVE_UPDATE_URL mixed with a provider/local selector -> UPDATE_SOURCE_CONFLICT +AINATIVE_UPDATE_URL alone -> anonymous release API +AINATIVE_UPDATE_PROVIDER= -> machine config or built-in +nothing selected -> machine default, else GitHub.com +``` + +Machine configuration lives at `~/.ai-native/release-providers.json` — machine +scope, deliberately: a cloned repository must never be able to redirect a +user's updates or name its own credential origin. The built-in names +(`github`, `gitlab`, `local`) cannot be redefined there, and V1 supports no +custom `auth_origin`: a named provider is anonymous. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path + +from . import transport as transportlib +from .errors import LifecycleError + +PROVIDER_ENV = "AINATIVE_UPDATE_PROVIDER" +LOCAL_SOURCE_ENV = "AINATIVE_UPDATE_LOCAL_DIR" +RELEASE_URL_ENV = "AINATIVE_UPDATE_URL" +CONFIG_RELATIVE = Path(".ai-native") / "release-providers.json" + +RESERVED_PROVIDERS = ("github", "gitlab", "local") +CONFIG_SCHEMA_VERSION = 1 + +KIND_GITHUB = "github" +KIND_LOCAL = "local" +KIND_ANONYMOUS = "anonymous" +KIND_NAMED = "named" + + +@dataclass(frozen=True) +class ReleaseSource: + """The resolved source, with the reason and the trust facts to display.""" + + kind: str + provider_name: str + reason: str + endpoint: transportlib.ReleaseProviderEndpointConfig | None = None + directory: Path | None = None + metadata_url: str | None = None + + @property + def authenticated(self) -> bool: + return bool(self.endpoint and self.endpoint.auth_origin is not None) + + def to_record(self) -> dict: + return {"kind": self.kind, "provider": self.provider_name, + "reason": self.reason, + "authenticated": self.authenticated, + "auth_origin": self.endpoint.auth_origin if self.endpoint else None, + "api_base_url": self.endpoint.api_base_url if self.endpoint else None, + "directory": str(self.directory) if self.directory else None} + + +def config_path(home: Path | None = None) -> Path: + return (Path(home) if home else Path.home()) / CONFIG_RELATIVE + + +def load_machine_config(home: Path | None = None) -> dict: + """The machine-scope provider configuration, validated, or {} when absent.""" + + path = config_path(home) + if not path.is_file(): + return {} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + raise LifecycleError("RELEASE_CONFIG_INVALID", + f"cannot read {path}: {error}") from error + if not isinstance(payload, dict): + raise LifecycleError("RELEASE_CONFIG_INVALID", f"{path} is not a JSON object") + version = payload.get("schema_version") + if version != CONFIG_SCHEMA_VERSION: + raise LifecycleError( + "RELEASE_CONFIG_INVALID", + f"{path}: schema_version {version!r} is not {CONFIG_SCHEMA_VERSION}; " + "upgrade the CLI rather than guessing") + providers = payload.get("providers", {}) + if not isinstance(providers, dict): + raise LifecycleError("RELEASE_CONFIG_INVALID", + f"{path}: providers must be an object") + for name in providers: + if name in RESERVED_PROVIDERS: + raise LifecycleError( + "RELEASE_CONFIG_INVALID", + f"{path}: provider {name!r} is built in and cannot be redefined") + default = payload.get("default_provider") + if default is not None and (not isinstance(default, str) or not default.strip()): + raise LifecycleError("RELEASE_CONFIG_INVALID", + f"{path}: default_provider must be a provider name") + return payload + + +def _named_source(name: str, entry: object) -> ReleaseSource: + if not isinstance(entry, dict): + raise LifecycleError("RELEASE_CONFIG_INVALID", + f"provider {name!r} must be an object") + if entry.get("auth_origin"): + raise LifecycleError( + "RELEASE_CONFIG_INVALID", + f"provider {name!r}: custom auth_origin is not supported in V1; " + "a named provider is anonymous") + base = entry.get("api_base_url") + if not isinstance(base, str) or not base.lower().startswith("https://"): + raise LifecycleError("RELEASE_CONFIG_INVALID", + f"provider {name!r}: api_base_url must be an HTTPS URL") + return ReleaseSource(kind=KIND_NAMED, provider_name=name, + endpoint=transportlib.anonymous_endpoint(base), + metadata_url=base.rstrip("/") + "/releases/latest", + reason=f"named provider {name!r} (machine configuration)") + + +def _builtin_github(reason: str) -> ReleaseSource: + # Deferred: provider owns the default URL and imports this module inside + # `build()`, so the import direction stays release_source -> provider. + from . import provider as providerlib + + return ReleaseSource(kind=KIND_GITHUB, provider_name="github", + endpoint=transportlib.GITHUB_ENDPOINT, + metadata_url=providerlib.DEFAULT_RELEASE_URL, + reason=reason) + + +def resolve_release_source(environ=None, home: Path | None = None) -> ReleaseSource: + """The one source decision. Conflicts refuse; nothing is ordered silently.""" + + environment = dict(os.environ if environ is None else environ) + config = load_machine_config(home) + + provider = (environment.get(PROVIDER_ENV) or "").strip().lower() + local_dir = (environment.get(LOCAL_SOURCE_ENV) or "").strip() + url = (environment.get(RELEASE_URL_ENV) or "").strip() + _reject_conflicts(provider=provider, local_dir=local_dir, url=url) + + if url: + return ReleaseSource(kind=KIND_ANONYMOUS, provider_name="anonymous", + endpoint=transportlib.anonymous_endpoint(url), + metadata_url=url, + reason=f"anonymous release API ({RELEASE_URL_ENV})") + if provider == "local": + return _local_mirror(local_dir) + if provider: + return _selected_provider(provider, config) + return _default_source(config) + + +def _reject_conflicts(*, provider: str, local_dir: str, url: str) -> None: + if url and (provider or local_dir): + raise LifecycleError( + "UPDATE_SOURCE_CONFLICT", + f"{RELEASE_URL_ENV} cannot be combined with {PROVIDER_ENV}/" + f"{LOCAL_SOURCE_ENV}; declare exactly one source", + selectors={RELEASE_URL_ENV: url, PROVIDER_ENV: provider, + LOCAL_SOURCE_ENV: local_dir}) + if local_dir and provider != "local": + raise LifecycleError( + "UPDATE_SOURCE_CONFLICT", + f"{LOCAL_SOURCE_ENV} requires an explicit {PROVIDER_ENV}=local; " + "selectors are never ordered", + selectors={LOCAL_SOURCE_ENV: local_dir, PROVIDER_ENV: provider}) + + +def _local_mirror(local_dir: str) -> ReleaseSource: + if not local_dir: + raise LifecycleError("UPDATE_CHECK_FAILED", + f"{PROVIDER_ENV}=local requires {LOCAL_SOURCE_ENV}") + return ReleaseSource(kind=KIND_LOCAL, provider_name="local", + directory=Path(local_dir).expanduser(), + reason=f"local mirror ({LOCAL_SOURCE_ENV})") + + +def _selected_provider(provider: str, config: dict) -> ReleaseSource: + if provider in ("github", "release-api"): + return _builtin_github(f"built-in GitHub.com ({PROVIDER_ENV})") + entry = (config.get("providers") or {}).get(provider) + if entry is None: + # Preserves the long-standing refusal for a name nothing declares; a + # declared-but-malformed entry is a config problem instead. + raise LifecycleError("UPDATE_CHECK_FAILED", + f"unknown update provider {provider!r}") + return _named_source(provider, entry) + + +def _default_source(config: dict) -> ReleaseSource: + default = str(config.get("default_provider") or "").strip().lower() + if default == "local": + entry = (config.get("providers") or {}).get("local") or {} + directory = entry.get("directory") if isinstance(entry, dict) else None + if not isinstance(directory, str) or not directory.strip(): + raise LifecycleError( + "RELEASE_CONFIG_INVALID", + "default_provider 'local' requires providers.local.directory " + "in the machine configuration") + return ReleaseSource(kind=KIND_LOCAL, provider_name="local", + directory=Path(directory).expanduser(), + reason="machine default provider 'local'") + if default == "github": + return _builtin_github("machine default provider 'github'") + if default: + return _named_source(default, (config.get("providers") or {}).get(default)) + return _builtin_github("built-in default (GitHub.com)") + + +def describe(environ=None, home: Path | None = None) -> dict: + """The source as diagnostics display it; a refusal is a state, not a crash.""" + + try: + return resolve_release_source(environ=environ, home=home).to_record() + except LifecycleError as error: + return {"kind": "refused", "state": error.code, "detail": error.message, + "authenticated": False, "auth_origin": None} + + +def describe_lines(record: dict | None = None) -> list[str]: + """`describe()`'s text rendering, shared by `status` and `doctor`.""" + + record = record if record is not None else describe() + if record.get("kind") == "refused": + return ["Release source", + f" refused: {record.get('state')} — {record.get('detail')}"] + lines = ["Release source", + f" {record.get('kind')} ({record.get('provider')}) — {record.get('reason')}"] + suffix = f" (auth origin {record.get('auth_origin')})" if record.get("auth_origin") else "" + lines.append(f" authenticated: {'yes' if record.get('authenticated') else 'no'}{suffix}") + return lines + + +__all__ = ["ReleaseSource", "resolve_release_source", "load_machine_config", + "config_path", "describe", "describe_lines", "PROVIDER_ENV", + "LOCAL_SOURCE_ENV", "RELEASE_URL_ENV", "CONFIG_RELATIVE", + "RESERVED_PROVIDERS", "KIND_GITHUB", "KIND_LOCAL", "KIND_ANONYMOUS", + "KIND_NAMED"] diff --git a/ainative/lifecycle/status.py b/ainative/lifecycle/status.py index c2761b64..55a054a6 100644 --- a/ainative/lifecycle/status.py +++ b/ainative/lifecycle/status.py @@ -12,6 +12,7 @@ from . import manifest as manifestlib from . import recovery as recoverylib +from . import release_source as release_sourcelib from . import state as statelib from . import updater as updaterlib from .manifest import Distribution @@ -30,6 +31,7 @@ class Status: components: list[dict] = field(default_factory=list) features: list[str] = field(default_factory=list) features_projected: bool = False + release_source: dict = field(default_factory=dict) healthy: bool = True lifecycle_notes: list[str] = field(default_factory=list) verified: dict = field(default_factory=dict) @@ -46,6 +48,7 @@ def to_record(self) -> dict: "components": self.components, "features": self.features, "features_projected_from_legacy": self.features_projected, + "release_source": self.release_source, "lifecycle": {"healthy": self.healthy, "notes": list(self.lifecycle_notes), "findings": self.counts}, "verified": self.verified, @@ -85,6 +88,9 @@ def render(self) -> str: lines.append("") lines.append("Updates") lines.append(f" {updaterlib.notice_line(self.update)}") + if self.release_source: + lines.append("") + lines.extend(release_sourcelib.describe_lines(self.release_source)) return "\n".join(lines) @@ -169,6 +175,7 @@ def build(project: Path, *, distribution: Distribution | None = None, notes.append("a lifecycle lock is present and may be stale") from . import features as featureslib + from . import release_source as release_sourcelib # One projection for every reader (ADR-0017 section 4): `status` reports # the effective feature set, exactly as `feature status` does. @@ -180,6 +187,7 @@ def build(project: Path, *, distribution: Distribution | None = None, components=_component_rows(distribution, state, diagnosis), features=list(effective.active_features) if effective else [], features_projected=bool(effective and effective.projected_from_legacy), + release_source=release_sourcelib.describe(), healthy=diagnosis.healthy, lifecycle_notes=notes, verified=_verified_section(project, distribution, state), update=updaterlib.cached_notice(project, allow_network=check_updates, diff --git a/tests/test_release_source.py b/tests/test_release_source.py new file mode 100644 index 00000000..b3008ec5 --- /dev/null +++ b/tests/test_release_source.py @@ -0,0 +1,199 @@ +"""One source resolver: selector conflicts refuse, and every command agrees. + +ADR-0019 sections 1–3: `resolve_release_source()` is the only decision, and it +validates before precedence — contradictory selectors are refused, never +ordered. The machine configuration is machine scope (a repository cannot +redirect a user's updates), reserved names cannot be redefined, and a named +provider is anonymous in V1. +""" + +from __future__ import annotations + +import json +import os +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from tests.lifecycle_support import LifecycleTestCase, write_text +from ainative.lifecycle import provider as providerlib +from ainative.lifecycle import release_source as sourcelib +from ainative.lifecycle.errors import LifecycleError + + +class TempHome(unittest.TestCase): + + def setUp(self) -> None: + self.root = Path(tempfile.mkdtemp(prefix="ainative-source-")) + self.addCleanup(shutil.rmtree, self.root, ignore_errors=True) + self.home = self.root / "home" + self.home.mkdir() + self.mirror = self.root / "mirror" + self.mirror.mkdir() + + def write_config(self, payload: dict) -> Path: + return write_text(sourcelib.config_path(self.home), json.dumps(payload) + "\n") + + +class SelectorMatrix(TempHome): + + def resolve(self, **environment): + return sourcelib.resolve_release_source(environ=environment, home=self.home) + + def test_nothing_selected_is_the_builtin_github(self): + source = self.resolve() + self.assertEqual(source.kind, sourcelib.KIND_GITHUB) + self.assertTrue(source.authenticated) + self.assertEqual(source.endpoint.auth_origin, "https://api.github.com") + self.assertEqual(source.metadata_url, providerlib.DEFAULT_RELEASE_URL) + + def test_the_local_pair_resolves_to_the_mirror(self): + source = self.resolve(AINATIVE_UPDATE_PROVIDER="local", + AINATIVE_UPDATE_LOCAL_DIR=str(self.mirror)) + self.assertEqual(source.kind, sourcelib.KIND_LOCAL) + self.assertEqual(source.directory, self.mirror) + self.assertFalse(source.authenticated) + + def test_a_local_dir_without_the_explicit_provider_is_a_conflict(self): + with self.assertRaises(LifecycleError) as raised: + self.resolve(AINATIVE_UPDATE_LOCAL_DIR=str(self.mirror)) + self.assertEqual(raised.exception.code, "UPDATE_SOURCE_CONFLICT") + + def test_the_url_mixed_with_a_selector_is_a_conflict(self): + cases = ({"AINATIVE_UPDATE_PROVIDER": "github"}, + {"AINATIVE_UPDATE_LOCAL_DIR": str(self.mirror)}, + {"AINATIVE_UPDATE_PROVIDER": "local", + "AINATIVE_UPDATE_LOCAL_DIR": str(self.mirror)}) + for extra in cases: + with self.subTest(extra=extra): + with self.assertRaises(LifecycleError) as raised: + self.resolve(AINATIVE_UPDATE_URL="https://mirror.example/releases/latest", + **extra) + self.assertEqual(raised.exception.code, "UPDATE_SOURCE_CONFLICT") + + def test_the_url_alone_is_anonymous(self): + source = self.resolve(AINATIVE_UPDATE_URL="https://mirror.example/releases/latest") + self.assertEqual(source.kind, sourcelib.KIND_ANONYMOUS) + self.assertFalse(source.authenticated) + self.assertIsNone(source.endpoint.auth_origin) + + def test_the_builtin_name_and_its_alias_resolve_to_github(self): + for name in ("github", "release-api"): + self.assertEqual(self.resolve(AINATIVE_UPDATE_PROVIDER=name).kind, + sourcelib.KIND_GITHUB) + + def test_an_unknown_provider_name_is_refused(self): + with self.assertRaises(LifecycleError) as raised: + self.resolve(AINATIVE_UPDATE_PROVIDER="carrier-pigeon") + self.assertEqual(raised.exception.code, "UPDATE_CHECK_FAILED") + + def test_local_without_a_directory_is_refused(self): + with self.assertRaises(LifecycleError) as raised: + self.resolve(AINATIVE_UPDATE_PROVIDER="local") + self.assertEqual(raised.exception.code, "UPDATE_CHECK_FAILED") + + +class MachineConfig(TempHome): + + def test_a_named_provider_resolves_anonymously(self): + self.write_config({"schema_version": 1, "providers": { + "internal": {"api_base_url": "https://releases.internal.example/api"}}}) + source = sourcelib.resolve_release_source( + environ={"AINATIVE_UPDATE_PROVIDER": "internal"}, home=self.home) + self.assertEqual(source.kind, sourcelib.KIND_NAMED) + self.assertFalse(source.authenticated) + self.assertEqual(source.metadata_url, + "https://releases.internal.example/api/releases/latest") + + def test_the_default_provider_applies_when_nothing_is_selected(self): + self.write_config({"schema_version": 1, "default_provider": "internal", + "providers": {"internal": { + "api_base_url": "https://releases.internal.example/api"}}}) + source = sourcelib.resolve_release_source(environ={}, home=self.home) + self.assertEqual(source.provider_name, "internal") + + def test_the_default_local_requires_a_directory(self): + self.write_config({"schema_version": 1, "default_provider": "local"}) + with self.assertRaises(LifecycleError) as raised: + sourcelib.resolve_release_source(environ={}, home=self.home) + self.assertEqual(raised.exception.code, "RELEASE_CONFIG_INVALID") + + def test_a_reserved_name_cannot_be_redefined(self): + self.write_config({"schema_version": 1, "providers": { + "github": {"api_base_url": "https://evil.example/api"}}}) + with self.assertRaises(LifecycleError) as raised: + sourcelib.resolve_release_source(environ={}, home=self.home) + self.assertEqual(raised.exception.code, "RELEASE_CONFIG_INVALID") + + def test_a_custom_auth_origin_is_refused_in_v1(self): + self.write_config({"schema_version": 1, "providers": { + "internal": {"api_base_url": "https://x.example/api", + "auth_origin": "https://x.example"}}}) + with self.assertRaises(LifecycleError) as raised: + sourcelib.resolve_release_source( + environ={"AINATIVE_UPDATE_PROVIDER": "internal"}, home=self.home) + self.assertEqual(raised.exception.code, "RELEASE_CONFIG_INVALID") + + def test_a_future_config_schema_is_refused_not_guessed(self): + self.write_config({"schema_version": 2, "providers": {}}) + with self.assertRaises(LifecycleError) as raised: + sourcelib.resolve_release_source(environ={}, home=self.home) + self.assertEqual(raised.exception.code, "RELEASE_CONFIG_INVALID") + + def test_describe_turns_a_conflict_into_a_state(self): + record = sourcelib.describe(environ={"AINATIVE_UPDATE_LOCAL_DIR": "x"}, + home=self.home) + self.assertEqual(record["kind"], "refused") + self.assertEqual(record["state"], "UPDATE_SOURCE_CONFLICT") + self.assertIn("Release source", sourcelib.describe_lines(record)[0]) + + +class ProviderBuild(TempHome): + + def test_build_delegates_to_the_resolver(self): + with mock.patch.object(sourcelib, "load_machine_config", lambda home=None: {}), \ + mock.patch.dict(os.environ, { + "AINATIVE_UPDATE_PROVIDER": "local", + "AINATIVE_UPDATE_LOCAL_DIR": str(self.mirror)}, clear=False): + built = providerlib.build("stable") + self.assertIsInstance(built, providerlib.LocalDirectoryProvider) + self.assertEqual(built.root, self.mirror) + + def test_build_of_the_default_is_the_github_endpoint_provider(self): + for name in (providerlib.PROVIDER_ENV, providerlib.LOCAL_SOURCE_ENV, + providerlib.RELEASE_URL_ENV): + os.environ.pop(name, None) + with mock.patch.object(sourcelib, "load_machine_config", lambda home=None: {}): + built = providerlib.build("stable") + self.assertIsInstance(built, providerlib.ReleaseApiProvider) + self.assertEqual(built.endpoint.auth_origin, "https://api.github.com") + self.assertEqual(built.url, providerlib.DEFAULT_RELEASE_URL) + + def test_build_of_an_anonymous_url_wears_no_credential(self): + with mock.patch.object(sourcelib, "load_machine_config", lambda home=None: {}), \ + mock.patch.dict(os.environ, { + "AINATIVE_UPDATE_URL": "https://mirror.example/releases/latest"}): + built = providerlib.build("stable") + self.assertIsNone(built.endpoint.auth_origin) + self.assertEqual(built._token(), "") + + +class Diagnostics(LifecycleTestCase): + + def test_status_and_doctor_display_the_source_without_secrets(self): + self.install("standard") + report = json.loads(self.cli("status", "--json").stdout) + self.assertEqual(report["release_source"]["kind"], "github") + self.assertTrue(report["release_source"]["authenticated"]) + text = self.cli("status").stdout + self.assertIn("Release source", text) + self.assertIn("authenticated: yes", text) + doctor = json.loads(self.cli("doctor", "--json").stdout) + self.assertEqual(doctor["release_source"]["reason"], + report["release_source"]["reason"]) + + +if __name__ == "__main__": + unittest.main() From 881529271af05a6a89a6ab19ef622d79ae191ea0 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Wed, 16 Sep 2026 20:46:16 +0200 Subject: [PATCH 09/14] feat(release): Release V3 contract, anchored manifest and exact version chain (#165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-4B + PR-4C. ainative/lifecycle/release_v3.py owns the V3 vocabulary and the fail-closed policies around it (ADR-0019 sections 7-10): - Candidate policy: installable versions are SemVer without build metadata (1.2.3, 1.2.3-rc.1); `1.2.3+build1` refuses as RELEASE_BUILD_METADATA_UNSUPPORTED; ordering uses version precedence, never string order; two identities on one version refuse as RELEASE_DUPLICATE_VERSION (never "first match"); an enumeration that stopped at its bounds refuses as RELEASE_ENUMERATION_INCOMPLETE; a complete channel with nothing refuses as RELEASE_NO_CANDIDATE. - The external anchor travels WITH the candidate (provider metadata): manifest sha256 + size are verified BEFORE the document is parsed — RELEASE_INTEGRITY_METADATA_MISSING / _INVALID for absent/malformed anchors, UPDATE_INTEGRITY_FAILED for any mismatch. A hostile manifest can never influence whether its own bytes are trusted. - ReleaseManifest V3: schema, protocol, version, channel, compatibility, artifacts, provenance — strictly validated (plain filenames, exact one lifecycle artifact, canonical versions). A newer protocol refuses as CLI_UPDATE_REQUIRED, mirroring the bridge; anything else is an integrity refusal. - The exact version chain: candidate == manifest == compatibility.runtime_version == artifact == artifact filename == lifecycle-protocol.json release_version, with the broken link named in the refusal detail. `resolve_manifest()` formalizes the order: enumerate -> select -> verify -> parse -> chain. Tests: 32 in tests/test_release_v3.py (SemVer policy, selection, anchors, manifest refusals, every broken chain link, resolution order with a fake provider). CI runs them in the lifecycle job. Verified: 309 lifecycle tests OK, complexity 0 findings, LOC 0 warnings, scope within tolerance. Refs #165 --- .github/workflows/ci.yml | 3 + ainative/lifecycle/errors.py | 7 + ainative/lifecycle/release_v3.py | 393 +++++++++++++++++++++++++++++++ tests/test_release_v3.py | 319 +++++++++++++++++++++++++ 4 files changed, 722 insertions(+) create mode 100644 ainative/lifecycle/release_v3.py create mode 100644 tests/test_release_v3.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2194b86..ba653d8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -210,6 +210,9 @@ jobs: - name: Release source resolution and diagnostics run: python -m unittest tests.test_release_source -v + - name: Release V3 contract, manifest and version chain + run: python -m unittest tests.test_release_v3 -v + - name: Path traversal, link escape, tampered state, archive safety run: python -m unittest tests.test_lifecycle_security -v diff --git a/ainative/lifecycle/errors.py b/ainative/lifecycle/errors.py index 0ebb233e..6e02074a 100644 --- a/ainative/lifecycle/errors.py +++ b/ainative/lifecycle/errors.py @@ -36,6 +36,13 @@ "UPDATE_CHECK_FAILED": EXIT_FAILED, "UPDATE_SOURCE_CONFLICT": EXIT_INVALID_REQUEST, "RELEASE_CONFIG_INVALID": EXIT_INVALID_REQUEST, + # Release V3 (ADR-0019). Enumeration, SemVer and integrity refusals. + "RELEASE_ENUMERATION_INCOMPLETE": EXIT_FAILED, + "RELEASE_NO_CANDIDATE": EXIT_FAILED, + "RELEASE_BUILD_METADATA_UNSUPPORTED": EXIT_FAILED, + "RELEASE_DUPLICATE_VERSION": EXIT_FAILED, + "RELEASE_INTEGRITY_METADATA_MISSING": EXIT_FAILED, + "RELEASE_INTEGRITY_METADATA_INVALID": EXIT_FAILED, "UPDATE_INTEGRITY_FAILED": EXIT_FAILED, "UPDATE_INTEGRITY_METADATA_MISSING": EXIT_FAILED, "UPDATE_VERSION_MISMATCH": EXIT_FAILED, diff --git a/ainative/lifecycle/release_v3.py b/ainative/lifecycle/release_v3.py new file mode 100644 index 00000000..32109ed5 --- /dev/null +++ b/ainative/lifecycle/release_v3.py @@ -0,0 +1,393 @@ +"""Release V3: the provider contract, the manifest, and the chains that bind them. + +ADR-0019 sections 7–10. Three ideas, each fail-closed: + +* A candidate is installable only when its version is SemVer without build + metadata, and two candidates must not claim the same precedence with + different identities (`RELEASE_DUPLICATE_VERSION`). +* An enumeration that stopped at its bounds is incomplete + (`RELEASE_ENUMERATION_INCOMPLETE`); a complete channel with nothing is + `RELEASE_NO_CANDIDATE`. Partial results are never used silently. +* The manifest's SHA-256 and size come from provider metadata, never from the + manifest itself: they are verified **before** the document is parsed, so + nothing a hostile manifest says can influence whether its own bytes are + trusted. The version chain then requires one version across candidate, + manifest, compatibility.runtime_version, artifact, artifact filename and the + lifecycle protocol document. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from typing import Iterable + +from . import version as versionlib +from .digest import digest_bytes +from .errors import LifecycleError + +MANIFEST_SCHEMA = "ainative.release" +MANIFEST_PROTOCOL = "v3" +ARTIFACT_KIND_LIFECYCLE = "lifecycle" +ARTIFACT_KINDS = (ARTIFACT_KIND_LIFECYCLE,) + +_V3_BUNDLE_PREFIX = "ainative-lifecycle-v3-" +_V3_BUNDLE_SUFFIX = ".zip" +_SHA256 = re.compile(r"^[0-9a-f]{64}$") + + +@dataclass(frozen=True) +class ReleaseQuery: + """What a caller asks a provider for: one compatible channel.""" + + channel: str = "stable" + + +@dataclass(frozen=True) +class ReleaseCandidate: + """One enumerated release, with the anchor of its manifest. + + `manifest_sha256` and `manifest_size` are the external integrity anchor: + they come from the provider's own metadata (a release asset listing, a + mirror index), never from the manifest bytes themselves. + """ + + version: str + identity: str + channel: str = "stable" + manifest_sha256: str | None = None + manifest_size: int | None = None + + def to_record(self) -> dict: + return {"version": self.version, "identity": self.identity, + "channel": self.channel, + "manifest_sha256": self.manifest_sha256, + "manifest_size": self.manifest_size} + + +@dataclass(frozen=True) +class EnumerationResult: + """Everything the provider could see, and whether that was everything.""" + + candidates: tuple[ReleaseCandidate, ...] + complete: bool + + def to_record(self) -> dict: + return {"complete": self.complete, + "candidates": [candidate.to_record() for candidate in self.candidates]} + + +@dataclass(frozen=True) +class ManifestArtifact: + """One artifact the manifest declares. Verified, never trusted.""" + + name: str + kind: str + version: str + sha256: str + size: int + + def to_record(self) -> dict: + return {"name": self.name, "kind": self.kind, "version": self.version, + "sha256": self.sha256, "size": self.size} + + +@dataclass(frozen=True) +class ReleaseManifest: + """The parsed ReleaseManifest V3, already validated.""" + + version: str + channel: str + runtime_version: str + artifacts: tuple[ManifestArtifact, ...] + provenance: dict = field(default_factory=dict) + schema: str = MANIFEST_SCHEMA + protocol: str = MANIFEST_PROTOCOL + + def lifecycle_artifact(self) -> ManifestArtifact: + matches = [artifact for artifact in self.artifacts + if artifact.kind == ARTIFACT_KIND_LIFECYCLE] + if len(matches) != 1: + raise LifecycleError( + "UPDATE_INTEGRITY_FAILED", + f"the manifest declares {len(matches)} lifecycle artifacts; " + "exactly one is required") + return matches[0] + + def to_record(self) -> dict: + return {"schema": self.schema, "protocol": self.protocol, + "version": self.version, "channel": self.channel, + "compatibility": {"runtime_version": self.runtime_version}, + "artifacts": [artifact.to_record() for artifact in self.artifacts], + "provenance": dict(self.provenance)} + + +class ReleaseProvider: + """The V3 provider contract. Implementations fetch; this module decides.""" + + name = "abstract" + + def enumerate(self, query: ReleaseQuery) -> EnumerationResult: + raise NotImplementedError + + def fetch_manifest(self, candidate: ReleaseCandidate) -> bytes: + raise NotImplementedError + + def fetch_artifact(self, candidate: ReleaseCandidate, + artifact: ManifestArtifact) -> bytes: + raise NotImplementedError + + +def canonical_version(value: object) -> str: + """A SemVer version string without build metadata, or a refusal. + + `1.2.3` and `1.2.3-rc.1` are installable; `1.2.3+build1` is refused with + `RELEASE_BUILD_METADATA_UNSUPPORTED` because two artifacts must never share + an installable version identity. A value that is not SemVer at all keeps + the historical `UPDATE_CHECK_FAILED` refusal. + """ + + parsed = versionlib.parse(value) if isinstance(value, str) else None + if parsed is None: + raise LifecycleError("UPDATE_CHECK_FAILED", + f"release version {value!r} is not a SemVer version") + if "+" in parsed.raw: + raise LifecycleError( + "RELEASE_BUILD_METADATA_UNSUPPORTED", + f"release version {parsed.raw!r} carries build metadata; installable " + "versions are exactly major.minor.patch with an optional pre-release", + version=parsed.raw) + return str(parsed) + + +def select_candidate(result: EnumerationResult, query: ReleaseQuery) -> ReleaseCandidate: + """The newest installable candidate of the channel, or a refusal.""" + + if not result.complete: + raise LifecycleError( + "RELEASE_ENUMERATION_INCOMPLETE", + "the provider stopped enumerating at its bounds; partial results " + "are never used to pick a release") + compatible = [candidate for candidate in result.candidates + if candidate.channel == query.channel] + if not compatible: + raise LifecycleError( + "RELEASE_NO_CANDIDATE", + f"the {query.channel!r} channel is complete and holds no release") + by_precedence: dict[str, str] = {} + for candidate in compatible: + canonical = canonical_version(candidate.version) + previous = by_precedence.get(canonical) + if previous is not None and previous != candidate.identity: + raise LifecycleError( + "RELEASE_DUPLICATE_VERSION", + f"two releases claim version {canonical}: {previous!r} and " + f"{candidate.identity!r}; duplicates are never resolved by order", + version=canonical, identities=sorted({previous, candidate.identity})) + by_precedence[canonical] = candidate.identity + ordered = sorted(compatible, key=lambda candidate: versionlib.parse(candidate.version)) + return ordered[-1] + + +def verify_external_anchor(payload: bytes, *, sha256: str | None, size: int | None) -> None: + """Verify bytes against the anchor that travelled with the candidate. + + Absent anchor: `RELEASE_INTEGRITY_METADATA_MISSING`; malformed: + `RELEASE_INTEGRITY_METADATA_INVALID`; anything that does not match: + `UPDATE_INTEGRITY_FAILED`. This runs BEFORE parsing, always. + """ + + if sha256 is None and size is None: + raise LifecycleError( + "RELEASE_INTEGRITY_METADATA_MISSING", + "the provider published no manifest digest and size; a manifest " + "nobody anchored is not one this stack parses") + if not (isinstance(sha256, str) and _SHA256.match(sha256.strip().lower()) + and isinstance(size, int) and not isinstance(size, bool) and size > 0): + raise LifecycleError( + "RELEASE_INTEGRITY_METADATA_INVALID", + f"the manifest anchor is malformed (sha256={sha256!r}, size={size!r})") + if len(payload) != size: + raise LifecycleError( + "UPDATE_INTEGRITY_FAILED", + f"the manifest is {len(payload)} bytes but the provider declared {size}") + actual = digest_bytes(payload) + if actual != sha256.strip().lower(): + raise LifecycleError( + "UPDATE_INTEGRITY_FAILED", + f"the manifest digest {actual} does not match the anchored " + f"{sha256.strip().lower()}", + expected=sha256.strip().lower(), actual=actual) + + +def parse_manifest(payload: bytes) -> ReleaseManifest: + """Parse and validate a manifest whose bytes were already verified.""" + + try: + document = json.loads(payload.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as error: + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"the manifest is not valid JSON: {error}") from error + if not isinstance(document, dict): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", "the manifest is not an object") + if document.get("schema") != MANIFEST_SCHEMA: + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"unknown manifest schema {document.get('schema')!r}") + protocol = document.get("protocol") + if protocol != MANIFEST_PROTOCOL: + newer = _newer_protocol(protocol) + if newer is not None: + raise LifecycleError( + "CLI_UPDATE_REQUIRED", + f"the release manifest speaks protocol {protocol}; this runtime " + "speaks v3 and the release must be applied by a newer CLI runtime") + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"unknown manifest protocol {protocol!r}") + + raw_version = document.get("version") + if not isinstance(raw_version, str) or not raw_version.strip(): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", "the manifest declares no version") + version = canonical_version(raw_version) + channel = document.get("channel") + if not isinstance(channel, str) or not channel.strip(): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", "the manifest declares no channel") + compatibility = document.get("compatibility") + if not isinstance(compatibility, dict): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + "the manifest declares no compatibility object") + raw_runtime = compatibility.get("runtime_version") + if not isinstance(raw_runtime, str) or not raw_runtime.strip(): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + "the manifest declares no compatibility.runtime_version") + runtime_version = canonical_version(raw_runtime) + artifacts = _parse_artifacts(document.get("artifacts")) + provenance = document.get("provenance", {}) + if not isinstance(provenance, dict): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", "provenance must be an object") + return ReleaseManifest(version=version, channel=channel.strip(), + runtime_version=runtime_version, artifacts=artifacts, + provenance=dict(provenance)) + + +def _newer_protocol(protocol: object) -> int | None: + """The protocol number when it is a v newer than this runtime's, else None.""" + + match = re.match(r"^v(?P\d+)$", str(protocol)) + if match is None: + return None + number = int(match.group("number")) + return number if number > int(MANIFEST_PROTOCOL.lstrip("v")) else None + + +def _parse_artifacts(raw: object) -> tuple[ManifestArtifact, ...]: + if not isinstance(raw, list) or not raw: + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + "the manifest declares no artifacts") + artifacts = [] + for entry in raw: + artifacts.append(_parse_artifact(entry)) + return tuple(artifacts) + + +def _parse_artifact(entry: object) -> ManifestArtifact: + if not isinstance(entry, dict): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", "an artifact entry is not an object") + name = entry.get("name") + if not isinstance(name, str) or not name or "/" in name or "\\" in name \ + or name in (".", ".."): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"artifact name {name!r} is not a plain filename") + kind = entry.get("kind") + if kind not in ARTIFACT_KINDS: + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"artifact {name!r} declares unknown kind {kind!r}") + version = canonical_version(entry.get("version")) + sha = entry.get("sha256") + if not (isinstance(sha, str) and _SHA256.match(sha.strip().lower())): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"artifact {name!r} declares no valid sha256") + size = entry.get("size") + if not (isinstance(size, int) and not isinstance(size, bool) and size > 0): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"artifact {name!r} declares no valid size") + return ManifestArtifact(name=name, kind=kind, version=version, + sha256=sha.strip().lower(), size=size) + + +def lifecycle_bundle_name(version: str) -> str: + """The one filename a V3 lifecycle bundle of `version` may wear.""" + + return f"{_V3_BUNDLE_PREFIX}{version}{_V3_BUNDLE_SUFFIX}" + + +def require_exact_version_chain(candidate: ReleaseCandidate, manifest: ReleaseManifest, + artifact: ManifestArtifact, *, + protocol_release_version: str | None = None) -> None: + """One version across every trust-bearing identity, or `UPDATE_VERSION_MISMATCH`. + + candidate.version == manifest.version == manifest.compatibility.runtime_version + == artifact.version == the artifact filename version == (after extraction) + lifecycle-protocol.json.release_version. + """ + + links = [ + ("candidate version", candidate.version), + ("manifest version", manifest.version), + ("compatibility.runtime_version", manifest.runtime_version), + ("artifact version", artifact.version), + ("artifact filename version", _filename_version(artifact)), + ] + if protocol_release_version is not None: + links.append(("lifecycle-protocol.json release_version", + protocol_release_version)) + distinct = {value for _, value in links} + if len(distinct) != 1: + raise LifecycleError( + "UPDATE_VERSION_MISMATCH", + "; ".join(f"{label}={value!r}" for label, value in links), + chain={label: value for label, value in links}) + if artifact.name != lifecycle_bundle_name(artifact.version): + raise LifecycleError( + "UPDATE_VERSION_MISMATCH", + f"the artifact is named {artifact.name!r} but its version " + f"{artifact.version!r} requires {lifecycle_bundle_name(artifact.version)!r}", + published=artifact.name, + expected=lifecycle_bundle_name(artifact.version)) + + +def _filename_version(artifact: ManifestArtifact) -> str: + name = artifact.name + inner = name[len(_V3_BUNDLE_PREFIX):-len(_V3_BUNDLE_SUFFIX)] \ + if name.startswith(_V3_BUNDLE_PREFIX) and name.endswith(_V3_BUNDLE_SUFFIX) \ + else name + return inner + + +def resolve_manifest(provider: ReleaseProvider, query: ReleaseQuery + ) -> tuple[ReleaseCandidate, ReleaseManifest]: + """enumerate -> select -> anchor-verified manifest -> exact chain. + + The order is the trust order (ADR-0019 section 9): the manifest's bytes are + verified against provider metadata before the document is parsed, and the + version chain is checked before anything is downloaded. + """ + + result = provider.enumerate(query) + candidate = select_candidate(result, query) + payload = provider.fetch_manifest(candidate) + verify_external_anchor(payload, sha256=candidate.manifest_sha256, + size=candidate.manifest_size) + manifest = parse_manifest(payload) + artifact = manifest.lifecycle_artifact() + require_exact_version_chain(candidate, manifest, artifact) + return candidate, manifest + + +__all__ = [ + "ReleaseQuery", "ReleaseCandidate", "EnumerationResult", "ManifestArtifact", + "ReleaseManifest", "ReleaseProvider", "canonical_version", "select_candidate", + "verify_external_anchor", "parse_manifest", "lifecycle_bundle_name", + "require_exact_version_chain", "resolve_manifest", + "MANIFEST_SCHEMA", "MANIFEST_PROTOCOL", "ARTIFACT_KIND_LIFECYCLE", +] diff --git a/tests/test_release_v3.py b/tests/test_release_v3.py new file mode 100644 index 00000000..a0366476 --- /dev/null +++ b/tests/test_release_v3.py @@ -0,0 +1,319 @@ +"""Release V3: candidate policy, the anchored manifest, and the exact chain. + +ADR-0019 sections 7–10, pinned: build metadata is not an installable version, +duplicates are never resolved by order, an incomplete enumeration refuses +instead of picking the best of what it saw, the manifest is verified against +provider metadata BEFORE it is parsed, and one version must hold across every +trust-bearing identity. +""" + +from __future__ import annotations + +import json +import unittest +from hashlib import sha256 + +from ainative.lifecycle import release_v3 +from ainative.lifecycle.errors import LifecycleError + + +def manifest_payload(version: str = "2.5.0", *, runtime: str | None = None, + artifact_version: str | None = None, + artifact_name: str | None = None, + protocol: str = "v3", channel: str = "stable", + extra_artifacts: list | None = None, + **document_overrides) -> bytes: + artifact_version = artifact_version or version + artifacts = [{ + "name": artifact_name or release_v3.lifecycle_bundle_name(artifact_version), + "kind": "lifecycle", "version": artifact_version, + "sha256": "a" * 64, "size": 10, + }] + artifacts.extend(extra_artifacts or []) + document = { + "schema": "ainative.release", + "protocol": protocol, + "version": version, + "channel": channel, + "compatibility": {"runtime_version": runtime or version}, + "artifacts": artifacts, + "provenance": {"source": "test"}, + } + document.update(document_overrides) + return json.dumps(document).encode("utf-8") + + +def anchor(payload: bytes) -> tuple[str, int]: + return sha256(payload).hexdigest(), len(payload) + + +def candidate(version: str, *, identity: str | None = None, channel: str = "stable", + payload: bytes | None = None, anchored: bool = True): + digest, size = anchor(payload) if payload is not None else ("a" * 64, 10) + return release_v3.ReleaseCandidate( + version=version, identity=identity or f"release-{version}", channel=channel, + manifest_sha256=digest if anchored else None, + manifest_size=size if anchored else None) + + +def expect(code: str, action) -> LifecycleError: + try: + action() + except LifecycleError as error: + if error.code != code: + raise AssertionError(f"expected {code}, got {error.code}: " + f"{error.message}") from error + return error + raise AssertionError(f"expected a {code} refusal; nothing was raised") + + +class SemVerPolicy(unittest.TestCase): + + def test_canonical_versions_are_semver_without_build_metadata(self): + self.assertEqual(release_v3.canonical_version("1.2.3"), "1.2.3") + self.assertEqual(release_v3.canonical_version("v1.2.3"), "1.2.3") + self.assertEqual(release_v3.canonical_version("1.2.3-rc.1"), "1.2.3-rc.1") + + def test_build_metadata_is_not_an_installable_version(self): + expect("RELEASE_BUILD_METADATA_UNSUPPORTED", + lambda: release_v3.canonical_version("1.2.3+build1")) + + def test_a_non_semver_candidate_keeps_the_historical_refusal(self): + expect("UPDATE_CHECK_FAILED", lambda: release_v3.canonical_version("nightly")) + + +class CandidateSelection(unittest.TestCase): + + def select(self, candidates, *, complete=True, channel="stable"): + result = release_v3.EnumerationResult(tuple(candidates), complete) + return release_v3.select_candidate(result, release_v3.ReleaseQuery(channel)) + + def test_order_is_version_order_not_string_order(self): + chosen = self.select([candidate("1.9.0"), candidate("1.10.0")]) + self.assertEqual(chosen.version, "1.10.0") + + def test_a_release_outranks_its_pre_releases(self): + chosen = self.select([candidate("1.2.3-rc.1"), candidate("1.2.3")]) + self.assertEqual(chosen.version, "1.2.3") + chosen = self.select([candidate("1.2.3-rc.1"), candidate("1.2.3-rc.2")]) + self.assertEqual(chosen.version, "1.2.3-rc.2") + + def test_another_channel_is_not_a_candidate(self): + expect("RELEASE_NO_CANDIDATE", + lambda: self.select([candidate("1.2.3", channel="beta")])) + + def test_an_incomplete_enumeration_refuses_even_with_candidates(self): + expect("RELEASE_ENUMERATION_INCOMPLETE", + lambda: self.select([candidate("1.2.3")], complete=False)) + + def test_a_complete_channel_with_nothing_refuses(self): + expect("RELEASE_NO_CANDIDATE", lambda: self.select([])) + + def test_two_identities_on_one_version_refuse_as_duplicates(self): + error = expect("RELEASE_DUPLICATE_VERSION", + lambda: self.select([candidate("1.2.3", identity="tag-a"), + candidate("1.2.3", identity="tag-b")])) + self.assertEqual(error.detail["version"], "1.2.3") + + def test_the_same_identity_listed_twice_is_one_candidate(self): + chosen = self.select([candidate("1.2.3", identity="tag-a"), + candidate("1.2.3", identity="tag-a")]) + self.assertEqual(chosen.identity, "tag-a") + + +class TrustAnchor(unittest.TestCase): + + def verify(self, payload, *, sha=None, size=None): + release_v3.verify_external_anchor(payload, sha256=sha, size=size) + + def test_an_absent_anchor_is_missing_metadata(self): + expect("RELEASE_INTEGRITY_METADATA_MISSING", + lambda: self.verify(b"{}", sha=None, size=None)) + + def test_a_malformed_anchor_is_invalid_metadata(self): + for sha, size in (("zz", 2), ("a" * 64, 0), ("a" * 64, "2")): + with self.subTest(sha=sha, size=size): + expect("RELEASE_INTEGRITY_METADATA_INVALID", + lambda sha=sha, size=size: self.verify(b"{}", sha=sha, size=size)) + + def test_a_size_mismatch_is_refused(self): + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.verify(b"{}", sha="a" * 64, size=99)) + + def test_a_digest_mismatch_is_refused(self): + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.verify(b"{}", sha="a" * 64, size=2)) + + def test_a_matching_anchor_passes(self): + digest, size = anchor(b'{"ok": true}') + self.verify(b'{"ok": true}', sha=digest, size=size) + + def test_verification_happens_before_parsing(self): + # Invalid JSON with a WRONG digest must fail on the digest, proving the + # bytes were verified before being parsed; with a correct anchor the + # same bytes reach the parser and fail there. + error = expect("UPDATE_INTEGRITY_FAILED", + lambda: self._verify_then_parse(b"{not json")) + self.assertIn("does not match", error.message) + error = expect("UPDATE_INTEGRITY_FAILED", + lambda: release_v3.parse_manifest(b"{not json")) + self.assertIn("not valid JSON", error.message) + + def _verify_then_parse(self, payload: bytes) -> None: + self.verify(payload, sha="a" * 64, size=len(payload)) + release_v3.parse_manifest(payload) + + +class ManifestValidation(unittest.TestCase): + + def parse(self, payload: bytes): + return release_v3.parse_manifest(payload) + + def test_a_valid_manifest_round_trips(self): + manifest = self.parse(manifest_payload()) + self.assertEqual(manifest.version, "2.5.0") + self.assertEqual(manifest.runtime_version, "2.5.0") + self.assertEqual(manifest.channel, "stable") + self.assertEqual(manifest.lifecycle_artifact().name, + "ainative-lifecycle-v3-2.5.0.zip") + self.assertEqual(manifest.to_record()["provenance"], {"source": "test"}) + + def test_a_newer_protocol_asks_for_a_newer_cli(self): + expect("CLI_UPDATE_REQUIRED", + lambda: self.parse(manifest_payload(protocol="v4"))) + + def test_an_unknown_protocol_is_an_integrity_refusal(self): + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.parse(manifest_payload(protocol="v2"))) + + def test_an_unknown_schema_is_refused(self): + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.parse(manifest_payload(schema="something.else"))) + + def test_a_build_metadata_manifest_version_is_refused(self): + expect("RELEASE_BUILD_METADATA_UNSUPPORTED", + lambda: self.parse(manifest_payload(version="2.5.0+build1"))) + + def test_missing_pieces_are_refused(self): + for overrides in ({"channel": ""}, {"compatibility": None}, + {"artifacts": []}, {"provenance": []}, + {"version": None}): + with self.subTest(overrides=overrides): + expect("UPDATE_INTEGRITY_FAILED", + lambda overrides=overrides: self.parse( + manifest_payload(**overrides))) + + def test_a_traversing_artifact_name_is_refused(self): + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.parse(manifest_payload(artifact_name="../../evil.zip"))) + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.parse(manifest_payload(artifact_name="sub/evil.zip"))) + + def test_an_unknown_artifact_kind_is_refused(self): + digest, size = anchor(b"x") + extra = [{"name": "wheel.whl", "kind": "wheel", "version": "2.5.0", + "sha256": "a" * 64, "size": 10}] + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.parse(manifest_payload(extra_artifacts=extra))) + + def test_a_malformed_artifact_digest_is_refused(self): + payload = manifest_payload() + document = json.loads(payload) + document["artifacts"][0]["sha256"] = "nope" + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.parse(json.dumps(document).encode())) + + def test_two_lifecycle_artifacts_are_ambiguous(self): + second = {"name": "ainative-lifecycle-v3-2.5.0.zip", "kind": "lifecycle", + "version": "2.5.0", "sha256": "b" * 64, "size": 11} + manifest = self.parse(manifest_payload(extra_artifacts=[second])) + expect("UPDATE_INTEGRITY_FAILED", manifest.lifecycle_artifact) + + +class VersionChain(unittest.TestCase): + + def chain(self, *, candidate_version="2.5.0", manifest_payload_args=None, + protocol_release_version=None): + payload = manifest_payload(**(manifest_payload_args or {})) + manifest = release_v3.parse_manifest(payload) + release_v3.require_exact_version_chain( + candidate(candidate_version), manifest, manifest.lifecycle_artifact(), + protocol_release_version=protocol_release_version) + + def test_the_exact_chain_passes(self): + self.chain(protocol_release_version="2.5.0") + + def test_every_broken_link_is_refused(self): + cases = ( + {"candidate_version": "2.5.1"}, + {"manifest_payload_args": {"version": "2.5.1"}}, + {"manifest_payload_args": {"runtime": "2.4.9"}}, + {"manifest_payload_args": {"artifact_version": "2.4.9"}}, + {"manifest_payload_args": {"artifact_name": "ainative-lifecycle-v3-2.4.9.zip"}}, + {"protocol_release_version": "2.4.9"}, + ) + for case in cases: + with self.subTest(case=case): + error = expect("UPDATE_VERSION_MISMATCH", lambda case=case: self.chain(**case)) + self.assertIn("chain", error.detail) + + def test_the_chain_detail_names_every_link(self): + error = expect("UPDATE_VERSION_MISMATCH", + lambda: self.chain(candidate_version="2.5.1", + protocol_release_version="2.5.0")) + self.assertEqual(set(error.detail["chain"]), + {"candidate version", "manifest version", + "compatibility.runtime_version", "artifact version", + "artifact filename version", + "lifecycle-protocol.json release_version"}) + + +class ResolutionOrder(unittest.TestCase): + + class FakeProvider(release_v3.ReleaseProvider): + name = "fake" + + def __init__(self, candidates, payload, *, complete=True): + self._result = release_v3.EnumerationResult(tuple(candidates), complete) + self._payload = payload + self.calls: list[str] = [] + + def enumerate(self, query): + self.calls.append("enumerate") + return self._result + + def fetch_manifest(self, candidate): + self.calls.append(f"fetch_manifest:{candidate.version}") + return self._payload + + def fetch_artifact(self, candidate, artifact): + raise AssertionError("resolve_manifest must not download artifacts") + + def test_a_complete_chain_resolves(self): + payload = manifest_payload() + provider = self.FakeProvider([candidate("2.5.0", payload=payload)], payload) + resolved, manifest = release_v3.resolve_manifest( + provider, release_v3.ReleaseQuery("stable")) + self.assertEqual(resolved.version, "2.5.0") + self.assertEqual(manifest.version, "2.5.0") + self.assertEqual(provider.calls, ["enumerate", "fetch_manifest:2.5.0"]) + + def test_an_incomplete_enumeration_never_fetches_a_manifest(self): + payload = manifest_payload() + provider = self.FakeProvider([candidate("2.5.0", payload=payload)], payload, + complete=False) + expect("RELEASE_ENUMERATION_INCOMPLETE", + lambda: release_v3.resolve_manifest(provider, + release_v3.ReleaseQuery("stable"))) + self.assertEqual(provider.calls, ["enumerate"]) + + def test_an_unanchored_candidate_never_reaches_the_parser(self): + payload = manifest_payload() + provider = self.FakeProvider([candidate("2.5.0", anchored=False)], payload) + expect("RELEASE_INTEGRITY_METADATA_MISSING", + lambda: release_v3.resolve_manifest(provider, + release_v3.ReleaseQuery("stable"))) + + +if __name__ == "__main__": + unittest.main() From 3b8334c4e6008e4c67e88d805e9bdf74152c4e9a Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Wed, 16 Sep 2026 21:10:32 +0200 Subject: [PATCH 10/14] feat(release): V3 providers - GitHub, anonymous document, local mirror (#165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-4D + PR-4E. Each provider implements the `release_v3` contract and nothing else: enumerate, fetch the manifest, fetch an artifact. Every trust decision stays in release_v3 — the provider that fetched a manifest never decides whether to believe it (ADR-0019 sections 9-11). - GitHubReleaseProvider: bounded enumeration of the releases API (a full page says `complete=False`, so selection refuses instead of picking the best of what it saw); drafts, non-SemVer tags and releases without an `ainative-release-v3.json` asset are not candidates (a V2-era release is not a broken V3 one); the manifest asset's digest/size travel as the candidate's anchor; artifacts and manifests are fetched through the PR-0A transport (asset API locator preferred, `browser_download_url` fallback, octet-stream, bearer only at `api.github.com`). - AnonymousReleaseApiProvider: `AINATIVE_UPDATE_URL` as one GitHub-shaped release document, never authenticated; a document without a V3 manifest yields no candidate (`RELEASE_NO_CANDIDATE`), never a silent V2 fallback. - LocalReleaseProvider: `releases.json` channels carry the manifest locator with its size and SHA-256; the mirror executes the same logical chain as a network source, traversal-checked, bounded, and never trusted for being on disk. A V2-era channel entry is not a candidate. - `ReleaseCandidate` gains `manifest_locator` (provider-internal, opaque to the contract); `provider.environment_token()` exposes the environment token publicly for the V3 providers under the same origin rule. Tests: 16 in tests/test_release_providers.py, including an end-to-end `resolve_manifest` against the local mirror and against a scripted GitHub server, a tampered-manifest refusal, a bounded-listing refusal and a path-traversal refusal. CI runs them in the lifecycle job. Verified: 309 lifecycle tests OK, 67 release tests OK, complexity 0 findings, LOC 0 warnings, scope within tolerance. Refs #165 --- .github/workflows/ci.yml | 3 + ainative/lifecycle/provider.py | 12 +- ainative/lifecycle/release_providers.py | 324 ++++++++++++++++++++++++ ainative/lifecycle/release_v3.py | 3 + tests/test_release_providers.py | 324 ++++++++++++++++++++++++ 5 files changed, 665 insertions(+), 1 deletion(-) create mode 100644 ainative/lifecycle/release_providers.py create mode 100644 tests/test_release_providers.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba653d8e..d5ac56bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -213,6 +213,9 @@ jobs: - name: Release V3 contract, manifest and version chain run: python -m unittest tests.test_release_v3 -v + - name: Release V3 providers (GitHub, anonymous, local mirror) + run: python -m unittest tests.test_release_providers -v + - name: Path traversal, link escape, tampered state, archive safety run: python -m unittest tests.test_lifecycle_security -v diff --git a/ainative/lifecycle/provider.py b/ainative/lifecycle/provider.py index e4431a50..94cc48bd 100644 --- a/ainative/lifecycle/provider.py +++ b/ainative/lifecycle/provider.py @@ -44,6 +44,16 @@ def _update_token() -> str: return value return "" + +def environment_token() -> str: + """The token this environment offers, if any. Never logged anywhere. + + Public for the V3 providers, which attach it only when the transport's + endpoint rule allows the origin (PR-0A). + """ + + return _update_token() + DEFAULT_RELEASE_URL = "https://api.github.com/repos/Rwanbt/ai-native-dev-stack/releases/latest" # Authenticated checks, when the environment provides a token. Never logged, # never persisted: NAT-shared users hit the anonymous rate limit otherwise. @@ -448,5 +458,5 @@ def copy_tree(source: Path, destination: Path) -> None: "LIFECYCLE_BUNDLE_PREFIX", "LIFECYCLE_BUNDLE_SUFFIX", "NETWORK_TIMEOUT_SECONDS", "MAX_ARCHIVE_BYTES", "MAX_METADATA_BYTES", "ReleaseProviderEndpointConfig", - "upgrade_command", "UPGRADE_COMMAND_TEMPLATE", + "upgrade_command", "UPGRADE_COMMAND_TEMPLATE", "environment_token", ] \ No newline at end of file diff --git a/ainative/lifecycle/release_providers.py b/ainative/lifecycle/release_providers.py new file mode 100644 index 00000000..32841aeb --- /dev/null +++ b/ainative/lifecycle/release_providers.py @@ -0,0 +1,324 @@ +"""The V3 providers: GitHub.com, the anonymous release API, and a local mirror. + +Each provider implements the contract in `release_v3` and nothing else: it +enumerates what its source publishes, fetches the manifest bytes, fetches an +artifact. Every trust decision stays in `release_v3` — the provider that +fetched a manifest never decides whether to believe it (ADR-0019 sections 9–11). + +GitHub and the anonymous release document share the asset-selection rules of +the V2 path: an `assets[].url` API locator is preferred, `browser_download_url` +is the fallback, and neither is ever authenticated off the endpoint's declared +origin (PR-0A). The local mirror executes the same logical chain as a network +provider — its index supplies the manifest locator, size and SHA-256, and +nothing is trusted because "it is on disk". + +Enumeration is bounded. A provider that could not exhaust its listing says so +(`complete=False`), and `release_v3.select_candidate` then refuses rather than +picking the best of what it happened to see. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from . import release_v3 as release_v3lib +from . import transport as transportlib +from .errors import LifecycleError +from .paths import validate_relative + +MANIFEST_ASSET_NAME = "ainative-release-v3.json" +MAX_MANIFEST_BYTES = 1 << 20 # 1 MiB of release manifest is already absurd +MAX_ARCHIVE_BYTES = 256 << 20 # 256 MiB — mirrors the V2 bound +MAX_ENUMERATION_ENTRIES = 30 # one GitHub API page; beyond it, incomplete + + +def _json_object(payload: bytes, source: str) -> dict: + try: + document = json.loads(payload.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as error: + raise LifecycleError("UPDATE_CHECK_FAILED", + f"release metadata from {source} is not valid JSON: " + f"{error}") from error + if not isinstance(document, dict): + raise LifecycleError("UPDATE_CHECK_FAILED", + f"release metadata from {source} is not an object") + return document + + +def _json_list(payload: bytes, source: str) -> list: + try: + document = json.loads(payload.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as error: + raise LifecycleError("UPDATE_CHECK_FAILED", + f"release listing from {source} is not valid JSON: " + f"{error}") from error + if not isinstance(document, list): + raise LifecycleError("UPDATE_CHECK_FAILED", + f"release listing from {source} is not a list") + return document + + +def _asset_url(asset: dict) -> str | None: + for key in ("url", "browser_download_url"): + value = asset.get(key) + if isinstance(value, str) and value.lower().startswith("https://"): + return value + return None + + +def _manifest_asset(document: dict) -> dict | None: + for asset in document.get("assets") or []: + if isinstance(asset, dict) and asset.get("name") == MANIFEST_ASSET_NAME: + return asset + return None + + +def _anchor(asset: dict) -> tuple[str | None, int | None]: + raw_digest = asset.get("digest") + if isinstance(raw_digest, str) and raw_digest.startswith("sha256:"): + digest: str | None = raw_digest.split(":", 1)[1].strip().lower() + elif isinstance(raw_digest, str) and raw_digest.strip(): + # Present but not sha256: — kept so verification refuses as + # malformed metadata instead of missing metadata. + digest = raw_digest.strip() + else: + digest = None + size = asset.get("size") + if isinstance(size, bool) or not isinstance(size, int): + size = None + return digest, size + + +def _document_candidate(document: object) -> release_v3lib.ReleaseCandidate | None: + """A V3 candidate from a GitHub-shaped release document, or None. + + Not a candidate: a draft, a tag that is not SemVer (nothing installable), + or a release that publishes no V3 manifest asset — that last one is a + V2-era release, not a broken V3 one. + """ + + from . import version as versionlib + + if not isinstance(document, dict) or document.get("draft"): + return None + tag = document.get("tag_name") + if not isinstance(tag, str): + return None + parsed = versionlib.parse(tag) + if parsed is None: + return None + asset = _manifest_asset(document) + if asset is None: + return None + digest, size = _anchor(asset) + return release_v3lib.ReleaseCandidate( + version=str(parsed), identity=tag, + channel="beta" if document.get("prerelease") else "stable", + manifest_sha256=digest, manifest_size=size, + manifest_locator=_asset_url(asset)) + + +class _AssetDocumentProvider(release_v3lib.ReleaseProvider): + """Shared fetching for the providers whose releases are GitHub-shaped.""" + + def __init__(self) -> None: + self._documents: dict[str, dict] = {} + + def _remember(self, candidate: release_v3lib.ReleaseCandidate, + document: dict) -> None: + self._documents[candidate.identity] = document + + def _document(self, candidate: release_v3lib.ReleaseCandidate) -> dict: + try: + return self._documents[candidate.identity] + except KeyError: + raise LifecycleError( + "UPDATE_CHECK_FAILED", + "the provider must enumerate before it fetches") from None + + def _token(self) -> str: + return "" + + def _get(self, url: str, limit: int) -> bytes: + return transportlib.get(url, limit=limit, endpoint=self.endpoint, + kind=transportlib.ARTIFACT, + accept=transportlib.ACCEPT_OCTET_STREAM, + token=self._token()) + + def fetch_manifest(self, candidate: release_v3lib.ReleaseCandidate) -> bytes: + locator = candidate.manifest_locator or _asset_url( + _manifest_asset(self._document(candidate)) or {}) + if not locator: + raise LifecycleError("UPDATE_UNAVAILABLE", + f"release {candidate.identity} publishes no " + f"fetchable {MANIFEST_ASSET_NAME}") + return self._get(locator, MAX_MANIFEST_BYTES) + + def fetch_artifact(self, candidate: release_v3lib.ReleaseCandidate, + artifact: release_v3lib.ManifestArtifact) -> bytes: + document = self._document(candidate) + for asset in document.get("assets") or []: + if isinstance(asset, dict) and asset.get("name") == artifact.name: + url = _asset_url(asset) + if url: + return self._get(url, MAX_ARCHIVE_BYTES) + raise LifecycleError( + "UPDATE_UNAVAILABLE", + f"release {candidate.identity} publishes no asset {artifact.name!r}") + + +class GitHubReleaseProvider(_AssetDocumentProvider): + """GitHub Releases as a V3 source. Auth only at the configured origin.""" + + name = "github" + + def __init__(self, endpoint, repository: str = "Rwanbt/ai-native-dev-stack", + page_bound: int = MAX_ENUMERATION_ENTRIES) -> None: + super().__init__() + self.endpoint = endpoint + self.repository = repository + self.page_bound = page_bound + + def _token(self) -> str: + if self.endpoint.auth_origin is None: + return "" + from . import provider as providerlib + + return providerlib.environment_token() + + def enumerate(self, query: release_v3lib.ReleaseQuery) -> release_v3lib.EnumerationResult: + url = (f"{self.endpoint.api_base_url}/repos/{self.repository}" + f"/releases?per_page={self.page_bound}") + payload = transportlib.get(url, limit=MAX_MANIFEST_BYTES, endpoint=self.endpoint, + kind=transportlib.METADATA, + accept=transportlib.ACCEPT_GITHUB_JSON, + token=self._token()) + documents = _json_list(payload, url) + candidates = [] + for document in documents: + candidate = _document_candidate(document) + if candidate is None: + continue + self._remember(candidate, document) + candidates.append(candidate) + return release_v3lib.EnumerationResult(tuple(candidates), + complete=len(documents) < self.page_bound) + + +class AnonymousReleaseApiProvider(_AssetDocumentProvider): + """`AINATIVE_UPDATE_URL`: one release document, anonymous, GitHub-shaped.""" + + name = "anonymous" + + def __init__(self, url: str, + endpoint: transportlib.ReleaseProviderEndpointConfig | None = None) -> None: + super().__init__() + self.url = url + self.endpoint = endpoint or transportlib.anonymous_endpoint(url) + + def enumerate(self, query: release_v3lib.ReleaseQuery) -> release_v3lib.EnumerationResult: + payload = transportlib.get(self.url, limit=MAX_MANIFEST_BYTES, + endpoint=self.endpoint, + kind=transportlib.METADATA, + accept=transportlib.ACCEPT_GITHUB_JSON, + token=self._token()) + document = _json_object(payload, self.url) + candidate = _document_candidate(document) + if candidate is not None: + self._remember(candidate, document) + return release_v3lib.EnumerationResult((candidate,) if candidate else (), + complete=True) + + +class LocalReleaseProvider(release_v3lib.ReleaseProvider): + """A local mirror that executes the same logical chain as a network source. + + `releases.json` names, per channel, the version and the manifest locator + with its size and SHA-256 (`{"manifest": {"file": ..., "size": ..., + "sha256": ...}}`); the manifest then names the artifacts. A channel entry + without a V3 manifest paragraph is a V2-era entry and is not a candidate. + """ + + name = "local" + + def __init__(self, root: Path) -> None: + self.root = Path(root) + + def _index(self) -> dict: + path = self.root / "releases.json" + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + raise LifecycleError("UPDATE_CHECK_FAILED", + f"cannot read {path}: {error}") from error + if not isinstance(payload, dict): + raise LifecycleError("UPDATE_CHECK_FAILED", f"{path} is not a JSON object") + return payload + + def _candidate(self, channel: str, entry: object) -> release_v3lib.ReleaseCandidate | None: + if not isinstance(entry, dict): + return None + version = entry.get("version") + manifest = entry.get("manifest") + if not isinstance(version, str) or not version.strip() or not isinstance(manifest, dict): + return None + file = manifest.get("file") + locator = f"{version}/{file}" if isinstance(file, str) and file else None + sha = manifest.get("sha256") + size = manifest.get("size") + return release_v3lib.ReleaseCandidate( + version=version.strip(), identity=f"local:{channel}", channel=channel, + manifest_sha256=sha if isinstance(sha, str) else None, + manifest_size=size if isinstance(size, int) and not isinstance(size, bool) else None, + manifest_locator=locator) + + def enumerate(self, query: release_v3lib.ReleaseQuery) -> release_v3lib.EnumerationResult: + index = self._index() + channels = index.get("channels") + if not isinstance(channels, dict) or not channels: + raise LifecycleError("UPDATE_UNAVAILABLE", + f"{self.root / 'releases.json'} declares no channels") + candidates = [] + for channel, entry in sorted(channels.items()): + candidate = self._candidate(str(channel), entry) + if candidate is not None: + candidates.append(candidate) + return release_v3lib.EnumerationResult(tuple(candidates), complete=True) + + def _release_file(self, relative: str, *, limit: int, + description: str) -> bytes: + parts = validate_relative(relative) + path = self.root.joinpath(*parts.parts) + try: + size = path.stat().st_size + except OSError as error: + raise LifecycleError("UPDATE_UNAVAILABLE", + f"{description} is missing: {error}") from error + if size > limit: + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"{description} is {size} bytes, over the " + f"{limit} limit") + try: + return path.read_bytes() + except OSError as error: + raise LifecycleError("UPDATE_UNAVAILABLE", + f"cannot read {description}: {error}") from error + + def fetch_manifest(self, candidate: release_v3lib.ReleaseCandidate) -> bytes: + if not candidate.manifest_locator: + raise LifecycleError("UPDATE_UNAVAILABLE", + f"{candidate.identity} declares no manifest locator") + return self._release_file(candidate.manifest_locator, limit=MAX_MANIFEST_BYTES, + description=f"manifest for {candidate.identity}") + + def fetch_artifact(self, candidate: release_v3lib.ReleaseCandidate, + artifact: release_v3lib.ManifestArtifact) -> bytes: + return self._release_file(f"{candidate.version}/{artifact.name}", + limit=MAX_ARCHIVE_BYTES, + description=f"artifact {artifact.name!r}") + + +__all__ = ["GitHubReleaseProvider", "AnonymousReleaseApiProvider", + "LocalReleaseProvider", "MANIFEST_ASSET_NAME", "MAX_MANIFEST_BYTES", + "MAX_ARCHIVE_BYTES", "MAX_ENUMERATION_ENTRIES"] diff --git a/ainative/lifecycle/release_v3.py b/ainative/lifecycle/release_v3.py index 32109ed5..a62f0d70 100644 --- a/ainative/lifecycle/release_v3.py +++ b/ainative/lifecycle/release_v3.py @@ -51,6 +51,8 @@ class ReleaseCandidate: `manifest_sha256` and `manifest_size` are the external integrity anchor: they come from the provider's own metadata (a release asset listing, a mirror index), never from the manifest bytes themselves. + `manifest_locator` is the provider-internal way to fetch the manifest + (an asset API URL, a mirror path); it is opaque to this module. """ version: str @@ -58,6 +60,7 @@ class ReleaseCandidate: channel: str = "stable" manifest_sha256: str | None = None manifest_size: int | None = None + manifest_locator: str | None = None def to_record(self) -> dict: return {"version": self.version, "identity": self.identity, diff --git a/tests/test_release_providers.py b/tests/test_release_providers.py new file mode 100644 index 00000000..d78abc2b --- /dev/null +++ b/tests/test_release_providers.py @@ -0,0 +1,324 @@ +"""The V3 providers: GitHub, the anonymous document, and the local mirror. + +Providers fetch; release_v3 decides. These tests pin the fetching contract: +what is a candidate (drafts, non-SemVer tags and V2-era releases are not), how +the anchor travels from provider metadata, that a bounded listing says so, that +credentials follow the endpoint rule, and that the local mirror runs the same +logical verification chain as a network source. +""" + +from __future__ import annotations + +import json +import unittest +import urllib.error +import urllib.request +from hashlib import sha256 +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest import mock + +from tests.lifecycle_support import write_text +from ainative.lifecycle import release_providers as providerslib +from ainative.lifecycle import release_v3 as release_v3lib +from ainative.lifecycle import transport as transportlib +from ainative.lifecycle.errors import LifecycleError + + +class FakeResponse: + def __init__(self, payload: bytes) -> None: + self.payload = payload + + def read(self, limit: int) -> bytes: + return self.payload[:limit] + + def __enter__(self) -> "FakeResponse": + return self + + def __exit__(self, *args: object) -> bool: + return False + + +class ScriptedServer: + def __init__(self, hops: dict) -> None: + self.hops = hops + self.observed: list[tuple[str, str | None, str | None]] = [] + + def send(self, request: urllib.request.Request): + url = request.full_url + self.observed.append((url, request.get_header("Authorization"), + request.get_header("Accept"))) + if url not in self.hops: + raise AssertionError(f"unexpected request: {url}") + answer = self.hops[url] + if isinstance(answer, tuple) and answer[0] == "redirect": + raise urllib.error.HTTPError(url, 302, "Found", + {"Location": answer[1]}, None) + return FakeResponse(answer) + + +LIST_URL = "https://api.github.com/repos/o/r/releases?per_page=30" +MANIFEST_URL = "https://api.github.com/repos/o/r/releases/assets/1" + + +def release_document(version: str = "2.5.0", *, tag: str | None = None, + prerelease: bool = False, draft: bool = False, + manifest: bool = True, digest: str | None = "sha256:" + "a" * 64, + size: int | None = 10, artifact: bool = True) -> dict: + tag = tag or f"v{version}" + assets = [] + if manifest: + asset = {"name": "ainative-release-v3.json", "url": MANIFEST_URL, + "browser_download_url": f"https://github.com/o/r/releases/download/{tag}/" + "ainative-release-v3.json"} + if digest is not None: + asset["digest"] = digest + if size is not None: + asset["size"] = size + assets.append(asset) + if artifact: + assets.append({"name": release_v3lib.lifecycle_bundle_name(version), + "url": "https://api.github.com/repos/o/r/releases/assets/2", + "digest": "sha256:" + "b" * 64, "size": 20}) + return {"tag_name": tag, "prerelease": prerelease, "draft": draft, "assets": assets} + + +def anchored_manifest(version: str = "2.5.0") -> bytes: + document = { + "schema": "ainative.release", "protocol": "v3", "version": version, + "channel": "stable", + "compatibility": {"runtime_version": version}, + "artifacts": [{"name": release_v3lib.lifecycle_bundle_name(version), + "kind": "lifecycle", "version": version, + "sha256": "b" * 64, "size": 20}], + "provenance": {"source": "test"}, + } + return json.dumps(document).encode("utf-8") + + +def github_provider(server: ScriptedServer, **kwargs): + provider = providerslib.GitHubReleaseProvider( + transportlib.GITHUB_ENDPOINT, repository="o/r", **kwargs) + return provider, mock.patch.object(transportlib, "_send", server.send) + + +class GitHubEnumerate(unittest.TestCase): + + def enumerate(self, documents: list, **kwargs): + bound = kwargs.get("page_bound", 30) + server = ScriptedServer({ + f"https://api.github.com/repos/o/r/releases?per_page={bound}": + json.dumps(documents).encode("utf-8")}) + provider, patcher = github_provider(server, **kwargs) + with patcher: + return provider.enumerate(release_v3lib.ReleaseQuery("stable")) + + def test_a_v3_release_becomes_a_candidate_with_its_anchor(self): + result = self.enumerate([release_document()]) + self.assertTrue(result.complete) + candidate = result.candidates[0] + self.assertEqual(candidate.version, "2.5.0") + self.assertEqual(candidate.identity, "v2.5.0") + self.assertEqual(candidate.channel, "stable") + self.assertEqual(candidate.manifest_sha256, "a" * 64) + self.assertEqual(candidate.manifest_size, 10) + self.assertEqual(candidate.manifest_locator, MANIFEST_URL) + + def test_drafts_prereleases_and_non_v3_releases_are_not_candidates(self): + result = self.enumerate([ + release_document(draft=True), + release_document(version="2.4.0", prerelease=True), + release_document(version="2.3.0", manifest=False), + release_document(version="2.2.0", tag="nightly"), + ]) + self.assertEqual([candidate.version for candidate in result.candidates], ["2.4.0"]) + self.assertEqual(result.candidates[0].channel, "beta") + + def test_a_full_page_is_an_incomplete_enumeration(self): + result = self.enumerate([release_document(), release_document(version="2.4.0")], + page_bound=2) + self.assertFalse(result.complete) + + def test_a_missing_digest_is_carried_as_a_missing_anchor(self): + result = self.enumerate([release_document(digest=None, size=None)]) + self.assertIsNone(result.candidates[0].manifest_sha256) + self.assertIsNone(result.candidates[0].manifest_size) + + +class GitHubFetching(unittest.TestCase): + + def provider(self, server: ScriptedServer): + return github_provider(server) + + def test_fetch_manifest_uses_the_locator_with_the_artifact_policy(self): + manifest = anchored_manifest() + server = ScriptedServer({ + LIST_URL: json.dumps([release_document()]).encode("utf-8"), + MANIFEST_URL: manifest}) + provider, patcher = self.provider(server) + with patcher, mock.patch.dict("os.environ", {"GITHUB_TOKEN": "ghp_" + "x" * 36}): + candidate = provider.enumerate(release_v3lib.ReleaseQuery("stable")).candidates[0] + payload = provider.fetch_manifest(candidate) + self.assertEqual(payload, manifest) + self.assertEqual(server.observed[1][0], MANIFEST_URL) + self.assertEqual(server.observed[1][1], "Bearer ghp_" + "x" * 36) + self.assertEqual(server.observed[1][2], transportlib.ACCEPT_OCTET_STREAM) + + def test_fetch_artifact_picks_the_named_asset(self): + server = ScriptedServer({ + LIST_URL: json.dumps([release_document()]).encode("utf-8"), + "https://api.github.com/repos/o/r/releases/assets/2": b"bundle-bytes"}) + provider, patcher = self.provider(server) + with patcher: + candidate = provider.enumerate(release_v3lib.ReleaseQuery("stable")).candidates[0] + payload = provider.fetch_artifact( + candidate, release_v3lib.ManifestArtifact( + name=release_v3lib.lifecycle_bundle_name("2.5.0"), kind="lifecycle", + version="2.5.0", sha256="b" * 64, size=20)) + self.assertEqual(payload, b"bundle-bytes") + + def test_an_unknown_asset_is_refused(self): + server = ScriptedServer({ + LIST_URL: json.dumps([release_document()]).encode("utf-8")}) + provider, patcher = self.provider(server) + with patcher: + candidate = provider.enumerate(release_v3lib.ReleaseQuery("stable")).candidates[0] + with self.assertRaises(LifecycleError) as raised: + provider.fetch_artifact( + candidate, release_v3lib.ManifestArtifact( + name="other.zip", kind="lifecycle", version="2.5.0", + sha256="b" * 64, size=1)) + self.assertEqual(raised.exception.code, "UPDATE_UNAVAILABLE") + + def test_fetching_before_enumerating_is_a_programming_refusal(self): + provider = providerslib.GitHubReleaseProvider(transportlib.GITHUB_ENDPOINT, + repository="o/r") + with self.assertRaises(LifecycleError) as raised: + provider.fetch_manifest(release_v3lib.ReleaseCandidate( + version="2.5.0", identity="v2.5.0")) + self.assertEqual(raised.exception.code, "UPDATE_CHECK_FAILED") + + def test_resolve_manifest_runs_the_whole_chain(self): + manifest = anchored_manifest() + server = ScriptedServer({ + LIST_URL: json.dumps([release_document()]).encode("utf-8"), + MANIFEST_URL: manifest}) + provider, patcher = self.provider(server) + # The candidate's anchor must match the served manifest for the chain + # to pass: rebuild the listing with the real digest and size. + document = release_document(digest="sha256:" + sha256(manifest).hexdigest(), + size=len(manifest)) + server.hops[LIST_URL] = json.dumps([document]).encode("utf-8") + with patcher: + candidate, parsed = release_v3lib.resolve_manifest( + provider, release_v3lib.ReleaseQuery("stable")) + self.assertEqual(candidate.version, "2.5.0") + self.assertEqual(parsed.version, "2.5.0") + + +class AnonymousDocument(unittest.TestCase): + + URL = "https://mirror.example/releases/latest" + + def provider(self, document: object): + server = ScriptedServer({self.URL: json.dumps(document).encode("utf-8")}) + provider = providerslib.AnonymousReleaseApiProvider(self.URL) + return provider, mock.patch.object(transportlib, "_send", server.send), server + + def test_a_v3_document_yields_one_candidate_and_no_credential(self): + provider, patcher, server = self.provider(release_document()) + with patcher, mock.patch.dict("os.environ", {"GITHUB_TOKEN": "ghp_" + "x" * 36}): + result = provider.enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertTrue(result.complete) + self.assertEqual(len(result.candidates), 1) + self.assertEqual(server.observed[0][1], None, + "the anonymous source received a credential") + + def test_a_v2_document_yields_no_candidate(self): + provider, patcher, _server = self.provider(release_document(manifest=False)) + with patcher: + result = provider.enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertEqual(result.candidates, ()) + with self.assertRaises(LifecycleError) as raised: + release_v3lib.select_candidate(result, release_v3lib.ReleaseQuery("stable")) + self.assertEqual(raised.exception.code, "RELEASE_NO_CANDIDATE") + + +class LocalMirror(unittest.TestCase): + + VERSION = "2.5.0" + + def setUp(self) -> None: + self.directory = TemporaryDirectory(prefix="ainative-mirror-") + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.manifest = anchored_manifest(self.VERSION) + write_text(self.root / self.VERSION / "ainative-release-v3.json", self.manifest.decode()) + write_text(self.root / self.VERSION / release_v3lib.lifecycle_bundle_name(self.VERSION), + "bundle-bytes\n") + self.publish() + + def publish(self, *, sha: str | None = None, size: int | None = None, + version: str = "2.5.0") -> None: + payload = {"channels": {"stable": { + "version": version, + "manifest": { + "file": "ainative-release-v3.json", + "size": size if size is not None else len(self.manifest), + "sha256": sha if sha is not None else sha256(self.manifest).hexdigest(), + }}}} + write_text(self.root / "releases.json", json.dumps(payload)) + + def provider(self) -> providerslib.LocalReleaseProvider: + return providerslib.LocalReleaseProvider(self.root) + + def test_the_whole_chain_runs_on_a_local_mirror(self): + candidate, manifest = release_v3lib.resolve_manifest( + self.provider(), release_v3lib.ReleaseQuery("stable")) + self.assertEqual(candidate.version, self.VERSION) + self.assertEqual(manifest.version, self.VERSION) + payload = self.provider().fetch_artifact(candidate, manifest.lifecycle_artifact()) + self.assertEqual(payload, b"bundle-bytes\n") + + def test_a_tampered_manifest_is_refused_by_the_anchor(self): + write_text(self.root / self.VERSION / "ainative-release-v3.json", + self.manifest.decode().replace("2.5.0", "2.5.1")) + with self.assertRaises(LifecycleError) as raised: + release_v3lib.resolve_manifest(self.provider(), + release_v3lib.ReleaseQuery("stable")) + self.assertEqual(raised.exception.code, "UPDATE_INTEGRITY_FAILED") + + def test_a_v2_era_index_entry_is_not_a_candidate(self): + result = self.provider().enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertEqual(len(result.candidates), 1) + write_text(self.root / "releases.json", + json.dumps({"channels": {"stable": {"version": "2.5.0", + "archive": "stack.zip", + "sha256": "0" * 64}}})) + result = self.provider().enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertEqual(result.candidates, ()) + with self.assertRaises(LifecycleError) as raised: + release_v3lib.select_candidate(result, release_v3lib.ReleaseQuery("stable")) + self.assertEqual(raised.exception.code, "RELEASE_NO_CANDIDATE") + + def test_a_traversing_locator_cannot_leave_the_mirror(self): + candidate = release_v3lib.ReleaseCandidate( + version=self.VERSION, identity="local:stable", + manifest_locator="../outside/ainative-release-v3.json") + with self.assertRaises(LifecycleError) as raised: + self.provider().fetch_manifest(candidate) + self.assertEqual(raised.exception.code, "PATH_ESCAPE") + + def test_a_missing_artifact_is_refused(self): + candidate = release_v3lib.ReleaseCandidate( + version=self.VERSION, identity="local:stable") + with self.assertRaises(LifecycleError) as raised: + self.provider().fetch_artifact( + candidate, release_v3lib.ManifestArtifact( + name="never-shipped.zip", kind="lifecycle", version=self.VERSION, + sha256="b" * 64, size=1)) + self.assertEqual(raised.exception.code, "UPDATE_UNAVAILABLE") + + +if __name__ == "__main__": + unittest.main() From 5690b413e339ac024e937de597bb5a961c4c9d09 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Wed, 16 Sep 2026 21:20:23 +0200 Subject: [PATCH 11/14] feat(release): wire the updater to the V3 flow with a V2-era fallback (#165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-4 completion. `update check` and `update` now resolve through `resolve_v3_candidate()`: the resolved source is enumerated (bounded), the newest V3 candidate of the channel is selected, and the manifest is fetched and anchor-verified only when an update is applied — a check needs the version, nothing more. - A source that hits its enumeration bounds refuses (RELEASE_ENUMERATION_INCOMPLETE): partial results are never a reason to change generation. - A complete channel with no V3 candidate is a V2-era mirror, and the caller speaks the existing V2 path to it (deterministic, disclosed fallback — the bridge-era releases stay consumable). Everything else propagates. - apply(): V3 fetch verifies the artifact against the manifest's own size+SHA-256 before extraction, then the existing transaction applies the payload; the V3 bundle declares lifecycle protocol 3 (`_distribution_root` gained an `expected_protocol`), and a bundle carrying the V2 protocol refuses as UPDATE_VERSION_MISMATCH. - `provider.build_v3()` is the second patchable seam; the four tests that stub the V2 provider now stub it explicitly. Tests: 5 new end-to-end tests on a local V3 mirror (check, apply + rollback, tampered manifest refused with zero writes, protocol mismatch refused, CLI report). 314 lifecycle tests OK; release/CLI suites OK; gates green. Refs #165 --- .github/workflows/ci.yml | 3 + ainative/lifecycle/provider.py | 22 ++++ ainative/lifecycle/release_v3.py | 29 +++-- ainative/lifecycle/updater.py | 142 +++++++++++++++++------ tests/test_lifecycle_update_integrity.py | 6 +- tests/test_lifecycle_update_transport.py | 6 +- tests/test_lifecycle_update_v3.py | 122 +++++++++++++++++++ 7 files changed, 280 insertions(+), 50 deletions(-) create mode 100644 tests/test_lifecycle_update_v3.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5ac56bf..3de92789 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -216,6 +216,9 @@ jobs: - name: Release V3 providers (GitHub, anonymous, local mirror) run: python -m unittest tests.test_release_providers -v + - name: Release V3 update path end to end + run: python -m unittest tests.test_lifecycle_update_v3 -v + - name: Path traversal, link escape, tampered state, archive safety run: python -m unittest tests.test_lifecycle_security -v diff --git a/ainative/lifecycle/provider.py b/ainative/lifecycle/provider.py index 94cc48bd..b9d11146 100644 --- a/ainative/lifecycle/provider.py +++ b/ainative/lifecycle/provider.py @@ -444,6 +444,28 @@ def build(channel: str = "stable") -> UpdateProvider: return ReleaseApiProvider(url=source.metadata_url, endpoint=source.endpoint) +def build_v3(channel: str = "stable"): + """The V3 provider for the resolved source (ADR-0019 section 11). + + The second generation of the same seam: the V2 path speaks a `Release` + listing, the V3 path speaks bounded enumeration plus an anchored manifest. + The updater prefers V3 and falls back to V2 only when a complete + enumeration proves the channel is V2-era. + """ + + from . import release_providers as release_providerslib + from . import release_source as release_sourcelib + + source = release_sourcelib.resolve_release_source() + if source.kind == release_sourcelib.KIND_LOCAL: + return release_providerslib.LocalReleaseProvider(source.directory) + if source.kind == release_sourcelib.KIND_ANONYMOUS: + return release_providerslib.AnonymousReleaseApiProvider(source.metadata_url, + source.endpoint) + # github and named providers are both GitHub-Release-API-compatible. + return release_providerslib.GitHubReleaseProvider(source.endpoint) + + def copy_tree(source: Path, destination: Path) -> None: """Used by the local provider's tests to stage a fixture distribution.""" diff --git a/ainative/lifecycle/release_v3.py b/ainative/lifecycle/release_v3.py index a62f0d70..7ab6b23a 100644 --- a/ainative/lifecycle/release_v3.py +++ b/ainative/lifecycle/release_v3.py @@ -29,6 +29,9 @@ MANIFEST_SCHEMA = "ainative.release" MANIFEST_PROTOCOL = "v3" +# The `lifecycle-protocol.json` a V3 bundle carries at its root; the V2 bundle +# keeps protocol 2 (provider.UPDATE_PROTOCOL_VERSION). +BUNDLE_PROTOCOL_VERSION = 3 ARTIFACT_KIND_LIFECYCLE = "lifecycle" ARTIFACT_KINDS = (ARTIFACT_KIND_LIFECYCLE,) @@ -367,6 +370,19 @@ def _filename_version(artifact: ManifestArtifact) -> str: return inner +def fetch_manifest_for(provider: ReleaseProvider, + candidate: ReleaseCandidate) -> ReleaseManifest: + """Fetch, anchor-verify, parse and chain-check one candidate's manifest.""" + + payload = provider.fetch_manifest(candidate) + verify_external_anchor(payload, sha256=candidate.manifest_sha256, + size=candidate.manifest_size) + manifest = parse_manifest(payload) + artifact = manifest.lifecycle_artifact() + require_exact_version_chain(candidate, manifest, artifact) + return manifest + + def resolve_manifest(provider: ReleaseProvider, query: ReleaseQuery ) -> tuple[ReleaseCandidate, ReleaseManifest]: """enumerate -> select -> anchor-verified manifest -> exact chain. @@ -378,19 +394,14 @@ def resolve_manifest(provider: ReleaseProvider, query: ReleaseQuery result = provider.enumerate(query) candidate = select_candidate(result, query) - payload = provider.fetch_manifest(candidate) - verify_external_anchor(payload, sha256=candidate.manifest_sha256, - size=candidate.manifest_size) - manifest = parse_manifest(payload) - artifact = manifest.lifecycle_artifact() - require_exact_version_chain(candidate, manifest, artifact) - return candidate, manifest + return candidate, fetch_manifest_for(provider, candidate) __all__ = [ "ReleaseQuery", "ReleaseCandidate", "EnumerationResult", "ManifestArtifact", "ReleaseManifest", "ReleaseProvider", "canonical_version", "select_candidate", "verify_external_anchor", "parse_manifest", "lifecycle_bundle_name", - "require_exact_version_chain", "resolve_manifest", - "MANIFEST_SCHEMA", "MANIFEST_PROTOCOL", "ARTIFACT_KIND_LIFECYCLE", + "require_exact_version_chain", "resolve_manifest", "fetch_manifest_for", + "MANIFEST_SCHEMA", "MANIFEST_PROTOCOL", "BUNDLE_PROTOCOL_VERSION", + "ARTIFACT_KIND_LIFECYCLE", ] diff --git a/ainative/lifecycle/updater.py b/ainative/lifecycle/updater.py index 5f61513c..01d38c26 100644 --- a/ainative/lifecycle/updater.py +++ b/ainative/lifecycle/updater.py @@ -40,6 +40,7 @@ from . import manifest as manifestlib from . import planner as plannerlib from . import provider as providerlib +from . import release_v3 as release_v3lib from . import source as sourcelib from . import state as statelib from . import version as versionlib @@ -196,6 +197,50 @@ def checks_disabled(state: statelib.InstallState | None) -> bool: return not preferences.get("enabled", True) or not preferences.get("auto_check", True) +def _check_refusal(project: Path, current: str, error: LifecycleError, + record: bool) -> CheckResult: + """The check outcome for a refused source. Never fatal, never a traceback.""" + + if error.code == "CLI_UPDATE_REQUIRED": + # A future-protocol release IS available; this runtime cannot apply it. + target = error.detail.get("target_version") + result = _runtime_fields(CheckResult( + UPDATE_AVAILABLE if isinstance(target, str) else CHECK_FAILED, + current, latest=target if isinstance(target, str) else None, + detail=error.message, checked_at=statelib.now())) + else: + status = OFFLINE if error.code == "UPDATE_CHECK_FAILED" else CHECK_FAILED + result = _runtime_fields(CheckResult(status, current, detail=error.message, + checked_at=statelib.now())) + if record: + _write_cache(project, result) + return result + + +def resolve_v3_candidate(channel: str): + """(candidate, provider) for a V3 source, or None when the channel is V2-era. + + `None` means the source was exhausted and publishes no V3 candidate for the + channel — a V2-era mirror — and the caller speaks the V2 path to it. A + source that hit its enumeration bounds refuses instead: partial results are + never a reason to change generation (ADR-0019 section 7). + """ + + provider = providerlib.build_v3(channel) + if provider is None: + return None + result = provider.enumerate(release_v3lib.ReleaseQuery(channel)) + if not result.complete: + raise LifecycleError( + "RELEASE_ENUMERATION_INCOMPLETE", + "the release source answered a full page; its enumeration bounds " + "were reached before exhaustion") + if not any(candidate.channel == channel for candidate in result.candidates): + return None + candidate = release_v3lib.select_candidate(result, release_v3lib.ReleaseQuery(channel)) + return candidate, provider + + def check(project: Path, *, force: bool = False, allow_network: bool = True, record: bool = True, state: statelib.InstallState | None = None, source: DistributionSource | None = None, @@ -230,26 +275,19 @@ def check(project: Path, *, force: bool = False, allow_network: bool = True, if release is None: try: - release = providerlib.build(channel).latest(channel) + resolved = resolve_v3_candidate(channel) except LifecycleError as error: - if error.code == "CLI_UPDATE_REQUIRED": - # A future-protocol release IS available; this runtime cannot - # apply it. The old "no bundle to verify" answer sent users - # looking for a publishing defect instead of upgrading (#157). - target = error.detail.get("target_version") - result = _runtime_fields(CheckResult( - UPDATE_AVAILABLE if isinstance(target, str) else CHECK_FAILED, - current, latest=target if isinstance(target, str) else None, - detail=error.message, checked_at=statelib.now())) - if record: - _write_cache(project, result) - return result - status = OFFLINE if error.code == "UPDATE_CHECK_FAILED" else CHECK_FAILED - result = _runtime_fields(CheckResult(status, current, detail=error.message, - checked_at=statelib.now())) - if record: - _write_cache(project, result) - return result + return _check_refusal(project, current, error, record) + if resolved is not None: + # The version is all a check needs; the manifest is fetched only + # when an update is actually applied. + release = providerlib.Release(version=resolved[0].version, url=None, + digest=None) + else: + try: + release = providerlib.build(channel).latest(channel) + except LifecycleError as error: + return _check_refusal(project, current, error, record) newer = versionlib.is_newer(release.version, current) result = _runtime_fields(CheckResult(UPDATE_AVAILABLE if newer else UP_TO_DATE, current, @@ -379,7 +417,8 @@ def _safe_extract(payload: bytes, destination: Path) -> Path: return root -def _distribution_root(extracted: Path, release: providerlib.Release | None = None) -> Path: +def _distribution_root(extracted: Path, release: providerlib.Release | None = None, + *, expected_protocol: int | None = None) -> Path: """The payload root of an extracted bundle. A v2 bundle carries `lifecycle-protocol.json` at its root and the payload @@ -404,11 +443,13 @@ def _distribution_root(extracted: Path, release: providerlib.Release | None = No raise LifecycleError("UPDATE_INTEGRITY_FAILED", f"{providerlib.PROTOCOL_MANIFEST} is not an object") protocol_version = document.get("protocol_version") - if protocol_version != providerlib.UPDATE_PROTOCOL_VERSION: + expected = (expected_protocol if expected_protocol is not None + else providerlib.UPDATE_PROTOCOL_VERSION) + if protocol_version != expected: raise LifecycleError( "UPDATE_VERSION_MISMATCH", f"bundle declares lifecycle protocol {protocol_version!r}; this runtime " - f"speaks protocol {providerlib.UPDATE_PROTOCOL_VERSION}") + f"speaks protocol {expected}") declared = str(document.get("release_version", "")) if release is not None and declared != release.version: raise LifecycleError( @@ -491,6 +532,18 @@ def _require_matching_runtime(release: providerlib.Release) -> None: upgrade_command=upgrade_command(release.version)) +def _degraded_apply(project: Path, state: statelib.InstallState, + error: LifecycleError, dry_run: bool) -> UpdateResult: + """The update outcome when the source could not be consulted.""" + + outcome = _runtime_fields(CheckResult( + OFFLINE if error.code == "UPDATE_CHECK_FAILED" else CHECK_FAILED, + state.stack_version, detail=error.message, checked_at=statelib.now())) + if not dry_run: + _write_cache(project, outcome) + return UpdateResult(False, dry_run, state.stack_version, None, outcome) + + def apply(project: Path, *, dry_run: bool = False, force: bool = False, distribution: manifestlib.Distribution | None = None) -> UpdateResult: """resolve -> runtime gate -> check -> fetch -> verify -> apply -> commit. @@ -509,22 +562,26 @@ def apply(project: Path, *, dry_run: bool = False, force: bool = False, f"no AI Native installation recorded in {project}") channel = state.update_preferences.get("channel", "stable") or "stable" - provider = providerlib.build(channel) + v3 = None try: - release = provider.latest(channel) + v3 = resolve_v3_candidate(channel) except LifecycleError as error: - # An unreachable or empty source degrades gracefully, exactly as - # `check` reports it. A source that answered with something this stack - # refuses (mismatched bundle version, missing digest) is not degraded - # connectivity - it is a refusal, and it propagates. + # An unreachable source degrades gracefully, exactly as `check` + # reports it; anything a source answered with something this stack + # refuses is a refusal, and it propagates. if force or error.code not in ("UPDATE_CHECK_FAILED", "UPDATE_UNAVAILABLE"): raise - outcome = _runtime_fields(CheckResult( - OFFLINE if error.code == "UPDATE_CHECK_FAILED" else CHECK_FAILED, - state.stack_version, detail=error.message, checked_at=statelib.now())) - if not dry_run: - _write_cache(project, outcome) - return UpdateResult(False, dry_run, state.stack_version, None, outcome) + return _degraded_apply(project, state, error, dry_run) + if v3 is not None: + release = providerlib.Release(version=v3[0].version, url=None, digest=None) + else: + provider = providerlib.build(channel) + try: + release = provider.latest(channel) + except LifecycleError as error: + if force or error.code not in ("UPDATE_CHECK_FAILED", "UPDATE_UNAVAILABLE"): + raise + return _degraded_apply(project, state, error, dry_run) # Availability first, runtime contract second: a project already at the # newest release has nothing to apply, so a runtime that differs is not @@ -543,13 +600,24 @@ def apply(project: Path, *, dry_run: bool = False, force: bool = False, return UpdateResult(applied=False, dry_run=dry_run, from_version=state.stack_version, to_version=outcome.latest, check=outcome) - payload = provider.fetch(release) - providerlib.verify_archive(payload, release.digest) + protocol_version = providerlib.UPDATE_PROTOCOL_VERSION + if v3 is not None: + candidate, v3provider = v3 + manifest = release_v3lib.fetch_manifest_for(v3provider, candidate) + artifact = manifest.lifecycle_artifact() + payload = v3provider.fetch_artifact(candidate, artifact) + release_v3lib.verify_external_anchor(payload, sha256=artifact.sha256, + size=artifact.size) + protocol_version = release_v3lib.BUNDLE_PROTOCOL_VERSION + else: + payload = provider.fetch(release) + providerlib.verify_archive(payload, release.digest) staging = Path(tempfile.mkdtemp(prefix="ainative-update-", dir=str(_staging_root(project)))) try: - root = _distribution_root(_safe_extract(payload, staging), release) + root = _distribution_root(_safe_extract(payload, staging), release, + expected_protocol=protocol_version) staged_source = DistributionSource(root=root.resolve(), origin="update", version=sourcelib.read_version(root)) # The release named a version; the bytes name another one. SHA-256 diff --git a/tests/test_lifecycle_update_integrity.py b/tests/test_lifecycle_update_integrity.py index 564dc613..b5a7d255 100644 --- a/tests/test_lifecycle_update_integrity.py +++ b/tests/test_lifecycle_update_integrity.py @@ -133,7 +133,8 @@ def test_one_flipped_byte_refuses_before_any_write(self): mutated[-1] ^= 0xFF provider = self.provider(self.document(), bytes(mutated)) before = self.snapshot() - with mock.patch.object(providerlib, "build", lambda channel="stable": provider): + with mock.patch.object(providerlib, "build", lambda channel="stable": provider), \ + mock.patch.object(providerlib, "build_v3", lambda channel="stable": None): with self.assertRaises(LifecycleError) as raised: updaterlib.apply(self.project, distribution=self.distribution) self.assertEqual(raised.exception.code, "UPDATE_INTEGRITY_FAILED") @@ -142,7 +143,8 @@ def test_one_flipped_byte_refuses_before_any_write(self): def test_the_official_path_updates_end_to_end(self): provider = self.provider(self.document(), self.archive_bytes) - with mock.patch.object(providerlib, "build", lambda channel="stable": provider): + with mock.patch.object(providerlib, "build", lambda channel="stable": provider), \ + mock.patch.object(providerlib, "build_v3", lambda channel="stable": None): result = updaterlib.apply(self.project, distribution=self.distribution) self.assertTrue(result.applied) self.assertEqual(result.to_version, "2.0.0") diff --git a/tests/test_lifecycle_update_transport.py b/tests/test_lifecycle_update_transport.py index 6ccaef3c..a597b062 100644 --- a/tests/test_lifecycle_update_transport.py +++ b/tests/test_lifecycle_update_transport.py @@ -368,7 +368,8 @@ def test_a_bridge_style_release_stays_selectable_by_the_v2_runtime(self): def test_check_reports_a_future_release_as_available_with_a_cli_upgrade(self): self.install("standard") provider = self.provider(self.v3_document()) - with mock.patch.object(providerlib, "build", lambda channel="stable": provider): + with mock.patch.object(providerlib, "build", lambda channel="stable": provider), \ + mock.patch.object(providerlib, "build_v3", lambda channel="stable": None): result = updaterlib.check(self.project, force=True, record=False) self.assertEqual(result.status, updaterlib.UPDATE_AVAILABLE) self.assertEqual(result.latest, "3.0.0") @@ -378,7 +379,8 @@ def test_apply_refuses_a_future_release_before_any_write(self): self.install("standard") provider = self.provider(self.v3_document()) before = self.snapshot() - with mock.patch.object(providerlib, "build", lambda channel="stable": provider): + with mock.patch.object(providerlib, "build", lambda channel="stable": provider), \ + mock.patch.object(providerlib, "build_v3", lambda channel="stable": None): with self.assertRaises(LifecycleError) as raised: updaterlib.apply(self.project, distribution=self.distribution) self.assertEqual(raised.exception.code, "CLI_UPDATE_REQUIRED") diff --git a/tests/test_lifecycle_update_v3.py b/tests/test_lifecycle_update_v3.py new file mode 100644 index 00000000..e73e0e82 --- /dev/null +++ b/tests/test_lifecycle_update_v3.py @@ -0,0 +1,122 @@ +"""The V3 update path, end to end on a local mirror. + +The updater prefers V3 when the source publishes an anchored manifest, and +falls back to the V2 path only when a complete enumeration proves the channel +is V2-era. Everything that can refuse happens before the first project write. +""" + +from __future__ import annotations + +import json +import unittest +import zipfile +from hashlib import sha256 +from pathlib import Path + +from tests.lifecycle_support import LifecycleTestCase, build_distribution_tree, write_text +from ainative.lifecycle import provider as providerlib +from ainative.lifecycle import release_v3 as release_v3lib +from ainative.lifecycle import state as statelib +from ainative.lifecycle import updater as updaterlib +from ainative.lifecycle.errors import LifecycleError + +TARGET = "2.0.0" + + +def make_v3_bundle(directory: Path, version: str, *, protocol: int = 3) -> Path: + tree = build_distribution_tree(directory / f"dist-{version}", version) + path = directory / release_v3lib.lifecycle_bundle_name(version) + protocol_document = {"schema_name": "lifecycle_protocol", + "protocol_version": protocol, "release_version": version, + "payload_root": "stack"} + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("lifecycle-protocol.json", json.dumps(protocol_document)) + for item in sorted(tree.rglob("*")): + if item.is_file(): + archive.write(item, f"stack/{item.relative_to(tree).as_posix()}") + return path + + +class V3MirrorUpdate(LifecycleTestCase): + + def setUp(self) -> None: + super().setUp() + self.releases = self.root / "releases" + (self.releases / TARGET).mkdir(parents=True) + self.bundle = make_v3_bundle(self.releases / TARGET, TARGET) + self.manifest_document = { + "schema": "ainative.release", "protocol": "v3", "version": TARGET, + "channel": "stable", + "compatibility": {"runtime_version": TARGET}, + "artifacts": [{"name": release_v3lib.lifecycle_bundle_name(TARGET), + "kind": "lifecycle", "version": TARGET, + "sha256": "", "size": 0}], + "provenance": {"source": "test"}, + } + self.record_bundle() + self.publish() + self.set_env(providerlib.PROVIDER_ENV, "local") + self.set_env(providerlib.LOCAL_SOURCE_ENV, str(self.releases)) + self.install("standard") + self.assume_runtime(TARGET) + + def record_bundle(self) -> None: + payload = self.bundle.read_bytes() + self.manifest_document["artifacts"][0]["sha256"] = sha256(payload).hexdigest() + self.manifest_document["artifacts"][0]["size"] = len(payload) + + def publish(self, *, sha: str | None = None, size: int | None = None) -> None: + manifest = json.dumps(self.manifest_document).encode("utf-8") + write_text(self.releases / TARGET / "ainative-release-v3.json", + manifest.decode()) + index = {"channels": {"stable": {"version": TARGET, "manifest": { + "file": "ainative-release-v3.json", + "size": size if size is not None else len(manifest), + "sha256": sha if sha is not None else sha256(manifest).hexdigest()}}}} + write_text(self.releases / "releases.json", json.dumps(index)) + + def snapshot(self) -> dict: + from ainative.lifecycle.digest import digest_file + + return {path.relative_to(self.project).as_posix(): digest_file(path) or "" + for path in self.project.rglob("*") if path.is_file()} + + def test_check_sees_the_v3_release(self): + result = updaterlib.check(self.project, force=True, record=False) + self.assertEqual(result.status, updaterlib.UPDATE_AVAILABLE) + self.assertEqual(result.latest, TARGET) + + def test_a_v3_update_applies_end_to_end(self): + result = updaterlib.apply(self.project, distribution=self.distribution) + self.assertTrue(result.applied) + self.assertEqual(result.to_version, TARGET) + self.assertEqual(self.read("AGENTS.md"), f"# Engineering method {TARGET}\n") + self.assertEqual(statelib.load(self.project).stack_version, TARGET) + self.assertTrue(result.rollback_available) + + def test_a_tampered_v3_manifest_is_refused_before_any_write(self): + self.publish(sha="a" * 64) + before = self.snapshot() + with self.assertRaises(LifecycleError) as raised: + updaterlib.apply(self.project, distribution=self.distribution) + self.assertEqual(raised.exception.code, "UPDATE_INTEGRITY_FAILED") + self.assertEqual(self.snapshot(), before, + "a refused V3 update touched the project") + + def test_a_v3_bundle_carrying_the_v2_protocol_is_refused(self): + self.bundle.unlink() + self.bundle = make_v3_bundle(self.releases / TARGET, TARGET, protocol=2) + self.record_bundle() + self.publish() + with self.assertRaises(LifecycleError) as raised: + updaterlib.apply(self.project, distribution=self.distribution) + self.assertEqual(raised.exception.code, "UPDATE_VERSION_MISMATCH") + + def test_the_cli_reports_the_v3_check(self): + record = json.loads(self.cli("update", "check", "--force", "--json").stdout) + self.assertEqual(record["status"], updaterlib.UPDATE_AVAILABLE) + self.assertEqual(record["latest"], TARGET) + + +if __name__ == "__main__": + unittest.main() From 6f4946b296943d24c6de353f171d4d26ef35b7bf Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Wed, 16 Sep 2026 21:28:39 +0200 Subject: [PATCH 12/14] feat(release): GitLab provider, auth scheme per endpoint, purity tests (#166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-5 core. The GitLab provider implements the V3 contract (ADR-0019 §11): - Releases API for discovery only; the Generic Package Registry is the canonical distribution and integrity surface. Release Link URLs are never integrity roots — the anchor is the package file API's `file_sha256` and `size` (absent -> RELEASE_INTEGRITY_METADATA_MISSING, malformed -> RELEASE_INTEGRITY_METADATA_INVALID), and the manifest file lookup requires exactly one `ainative-release-v3.json` (0 -> RELEASE_MANIFEST_MISSING, >1 -> RELEASE_MANIFEST_AMBIGUOUS). - One project identity (`release_project_ref`) is used for Releases, packages and package files — no second project configuration. `release_project_ref` is a numeric id or namespace path, never a URL; it is machine-scope config (`providers.gitlab`), while `github`/`local` remain non-redefinable. - Exact package match (`type == generic`, canonical package name, selected version) with exactly one record, and bounded pagination (a full page refuses as incomplete, never a partial best-of). - Authentication shapes differ per provider: `ReleaseProviderEndpointConfig` gained `auth_header`/`auth_prefix`. GitLab sends `PRIVATE-TOKEN: ` from `GITLAB_TOKEN`, only at its configured origin; GitHub keeps `Authorization: Bearer`, unchanged. - GitLab is V3-only: `supports_v2_fallback = False`, and a complete channel without a V3 candidate refuses as RELEASE_NO_CANDIDATE instead of falling back to a generation that does not exist there. Tests: 10 GitLab provider tests on a scripted GitLab (exact-identity match, private-token confinement, duplicate packages, every manifest/integrity refusal, pagination bounds, full chain, anonymous endpoints) plus 3 config tests; and the three purity suites required by §74 (tests/purity/test_dependency_purity.py, test_contract_purity.py, test_distributed_policy_purity.py) — neutral modules do not import provider implementations, the neutral helpers behave identically for both forges, and the distributed policy carries no GitHub-only authority. Verified: 314 lifecycle tests OK, release suites OK, purity 7/7, complexity 0 findings, LOC 0 warnings. Refs #166 --- .github/workflows/ci.yml | 4 + ainative/lifecycle/errors.py | 2 + ainative/lifecycle/provider.py | 16 +- ainative/lifecycle/release_providers.py | 203 +++++++++++++++++- ainative/lifecycle/release_source.py | 60 +++++- ainative/lifecycle/release_v3.py | 8 +- ainative/lifecycle/transport.py | 9 +- ainative/lifecycle/updater.py | 5 + templates/AGENTS.md | 2 +- tests/purity/test_contract_purity.py | 68 ++++++ tests/purity/test_dependency_purity.py | 61 ++++++ .../purity/test_distributed_policy_purity.py | 49 +++++ tests/test_release_providers.py | 164 +++++++++++++- tests/test_release_source.py | 26 +++ 14 files changed, 665 insertions(+), 12 deletions(-) create mode 100644 tests/purity/test_contract_purity.py create mode 100644 tests/purity/test_dependency_purity.py create mode 100644 tests/purity/test_distributed_policy_purity.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3de92789..31bebc4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -219,6 +219,10 @@ jobs: - name: Release V3 update path end to end run: python -m unittest tests.test_lifecycle_update_v3 -v + - name: Purity - neutral layer, contracts, distributed policy + run: | + python -m unittest tests.purity.test_dependency_purity tests.purity.test_contract_purity tests.purity.test_distributed_policy_purity -v + - name: Path traversal, link escape, tampered state, archive safety run: python -m unittest tests.test_lifecycle_security -v diff --git a/ainative/lifecycle/errors.py b/ainative/lifecycle/errors.py index 6e02074a..8498fe9b 100644 --- a/ainative/lifecycle/errors.py +++ b/ainative/lifecycle/errors.py @@ -43,6 +43,8 @@ "RELEASE_DUPLICATE_VERSION": EXIT_FAILED, "RELEASE_INTEGRITY_METADATA_MISSING": EXIT_FAILED, "RELEASE_INTEGRITY_METADATA_INVALID": EXIT_FAILED, + "RELEASE_MANIFEST_MISSING": EXIT_FAILED, + "RELEASE_MANIFEST_AMBIGUOUS": EXIT_FAILED, "UPDATE_INTEGRITY_FAILED": EXIT_FAILED, "UPDATE_INTEGRITY_METADATA_MISSING": EXIT_FAILED, "UPDATE_VERSION_MISMATCH": EXIT_FAILED, diff --git a/ainative/lifecycle/provider.py b/ainative/lifecycle/provider.py index b9d11146..47fc943b 100644 --- a/ainative/lifecycle/provider.py +++ b/ainative/lifecycle/provider.py @@ -32,7 +32,8 @@ from .errors import LifecycleError # The selector names live with their owner (release_source); re-exported here # because they have always been importable through the provider module. -from .release_source import LOCAL_SOURCE_ENV, PROVIDER_ENV, RELEASE_URL_ENV +from .release_source import (GITLAB_TOKEN_ENV, LOCAL_SOURCE_ENV, PROVIDER_ENV, + RELEASE_URL_ENV) def _update_token() -> str: @@ -54,6 +55,12 @@ def environment_token() -> str: return _update_token() + +def environment_gitlab_token() -> str: + """The GitLab token this environment offers, if any. Never logged anywhere.""" + + return (os.environ.get(GITLAB_TOKEN_ENV) or "").strip() + DEFAULT_RELEASE_URL = "https://api.github.com/repos/Rwanbt/ai-native-dev-stack/releases/latest" # Authenticated checks, when the environment provides a token. Never logged, # never persisted: NAT-shared users hit the anonymous rate limit otherwise. @@ -439,6 +446,9 @@ def build(channel: str = "stable") -> UpdateProvider: from . import release_source as release_sourcelib source = release_sourcelib.resolve_release_source() + if source.kind == release_sourcelib.KIND_GITLAB: + raise LifecycleError("UPDATE_CHECK_FAILED", + "the gitlab provider speaks Release V3 only") if source.kind == release_sourcelib.KIND_LOCAL: return LocalDirectoryProvider(source.directory) return ReleaseApiProvider(url=source.metadata_url, endpoint=source.endpoint) @@ -462,6 +472,9 @@ def build_v3(channel: str = "stable"): if source.kind == release_sourcelib.KIND_ANONYMOUS: return release_providerslib.AnonymousReleaseApiProvider(source.metadata_url, source.endpoint) + if source.kind == release_sourcelib.KIND_GITLAB: + return release_providerslib.GitLabReleaseProvider(source.endpoint, + source.project_ref) # github and named providers are both GitHub-Release-API-compatible. return release_providerslib.GitHubReleaseProvider(source.endpoint) @@ -481,4 +494,5 @@ def copy_tree(source: Path, destination: Path) -> None: "NETWORK_TIMEOUT_SECONDS", "MAX_ARCHIVE_BYTES", "MAX_METADATA_BYTES", "ReleaseProviderEndpointConfig", "upgrade_command", "UPGRADE_COMMAND_TEMPLATE", "environment_token", + "environment_gitlab_token", ] \ No newline at end of file diff --git a/ainative/lifecycle/release_providers.py b/ainative/lifecycle/release_providers.py index 32841aeb..e72d890d 100644 --- a/ainative/lifecycle/release_providers.py +++ b/ainative/lifecycle/release_providers.py @@ -20,17 +20,20 @@ from __future__ import annotations import json +import urllib.parse from pathlib import Path from . import release_v3 as release_v3lib from . import transport as transportlib +from . import version as versionlib from .errors import LifecycleError from .paths import validate_relative MANIFEST_ASSET_NAME = "ainative-release-v3.json" MAX_MANIFEST_BYTES = 1 << 20 # 1 MiB of release manifest is already absurd MAX_ARCHIVE_BYTES = 256 << 20 # 256 MiB — mirrors the V2 bound -MAX_ENUMERATION_ENTRIES = 30 # one GitHub API page; beyond it, incomplete +MAX_ENUMERATION_ENTRIES = 30 # one API page; beyond it, incomplete +GITLAB_PACKAGE_NAME = "ai-native-dev-stack" def _json_object(payload: bytes, source: str) -> dict: @@ -319,6 +322,200 @@ def fetch_artifact(self, candidate: release_v3lib.ReleaseCandidate, description=f"artifact {artifact.name!r}") +class GitLabReleaseProvider(release_v3lib.ReleaseProvider): + """GitLab.com as a V3 source: Releases for discovery, the Generic Package + Registry as the canonical distribution and integrity surface (ADR-0019 + section 11). + + Release Link URLs are presentation, never integrity roots: the anchor is + the package file API's `file_sha256` and `size`. One project identity + (`release_project_ref`) is used for Releases, packages and package files — + there is no second project configuration. GitLab is V3-only: it has no V2 + fallback, and a complete channel without a V3 candidate refuses. + """ + + name = "gitlab" + supports_v2_fallback = False + + def __init__(self, endpoint, project_ref: str, + page_bound: int = MAX_ENUMERATION_ENTRIES) -> None: + self.endpoint = endpoint + self.project_ref = project_ref + self.page_bound = page_bound + self._downloads: dict[str, dict[str, str]] = {} + + # --- requests ------------------------------------------------------- + + def _token(self) -> str: + if self.endpoint.auth_origin is None: + return "" + from . import provider as providerlib + + return providerlib.environment_gitlab_token() + + def _project_url(self) -> str: + encoded = urllib.parse.quote(self.project_ref, safe="") + return f"{self.endpoint.api_base_url}/projects/{encoded}" + + def _get_json(self, url: str) -> object: + payload = transportlib.get(url, limit=MAX_MANIFEST_BYTES, endpoint=self.endpoint, + kind=transportlib.METADATA, + accept=transportlib.ACCEPT_GITHUB_JSON, + token=self._token()) + try: + return json.loads(payload.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as error: + raise LifecycleError("UPDATE_CHECK_FAILED", + f"GitLab answered {url} with invalid JSON: " + f"{error}") from error + + def _get_bytes(self, url: str, limit: int) -> bytes: + return transportlib.get(url, limit=limit, endpoint=self.endpoint, + kind=transportlib.ARTIFACT, + accept=transportlib.ACCEPT_OCTET_STREAM, + token=self._token()) + + # --- enumeration ---------------------------------------------------- + + def enumerate(self, query: release_v3lib.ReleaseQuery) -> release_v3lib.EnumerationResult: + releases, releases_complete = self._releases() + packages, packages_complete = self._packages() + candidates = self._candidates(releases, packages) + return release_v3lib.EnumerationResult( + tuple(candidates), complete=releases_complete and packages_complete) + + def _releases(self) -> tuple[list, bool]: + url = f"{self._project_url()}/releases?per_page={self.page_bound}" + document = self._get_json(url) + if not isinstance(document, list): + raise LifecycleError("UPDATE_CHECK_FAILED", + f"GitLab releases from {url} are not a list") + return document, len(document) < self.page_bound + + def _packages(self) -> tuple[dict, bool]: + url = (f"{self._project_url()}/packages?package_type=generic" + f"&package_name={GITLAB_PACKAGE_NAME}&per_page={self.page_bound}") + document = self._get_json(url) + if not isinstance(document, list): + raise LifecycleError("UPDATE_CHECK_FAILED", + f"GitLab packages from {url} are not a list") + by_version: dict[str, list] = {} + for record in document: + if not isinstance(record, dict): + continue + if (record.get("package_type") != "generic" + or record.get("name") != GITLAB_PACKAGE_NAME): + continue + parsed = versionlib.parse(record.get("version")) \ + if isinstance(record.get("version"), str) else None + if parsed is None: + continue + by_version.setdefault(str(parsed), []).append(record) + return by_version, len(document) < self.page_bound + + def _candidates(self, releases: list, packages: dict) -> list: + candidates = [] + for release in releases: + parsed = versionlib.parse(release.get("tag_name")) \ + if isinstance(release, dict) else None + if parsed is None or release.get("upcoming_release"): + continue + records = packages.get(str(parsed)) + if not records: + continue + if len(records) > 1: + raise LifecycleError( + "RELEASE_DUPLICATE_VERSION", + f"project {self.project_ref} publishes {len(records)} generic " + f"packages for version {parsed}; exactly one is required") + identity = str(release.get("tag_name")) + package_id = records[0].get("id") + files = self._package_files(package_id) + digest, size = self._anchor(files, identity) + self._downloads[identity] = self._download_urls(package_id, files) + candidates.append(release_v3lib.ReleaseCandidate( + version=str(parsed), identity=identity, + channel="beta" if parsed.pre else "stable", + manifest_sha256=digest, manifest_size=size)) + return candidates + + def _package_files(self, package_id: object) -> list: + url = (f"{self._project_url()}/packages/{package_id}/package_files" + f"?per_page={self.page_bound}") + document = self._get_json(url) + if not isinstance(document, list): + raise LifecycleError("UPDATE_CHECK_FAILED", + f"GitLab package files from {url} are not a list") + if len(document) >= self.page_bound: + raise LifecycleError( + "RELEASE_ENUMERATION_INCOMPLETE", + f"package {package_id} answered a full page of files; the " + "listing bounds were reached before exhaustion") + return [entry for entry in document if isinstance(entry, dict)] + + def _download_urls(self, package_id: object, files: list) -> dict: + return {str(entry.get("file_name")): + f"{self._project_url()}/packages/{package_id}/package_files/" + f"{entry.get('id')}/download" + for entry in files if entry.get("id") is not None} + + def _anchor(self, files: list, identity: str) -> tuple[str, int]: + manifests = [entry for entry in files + if entry.get("file_name") == MANIFEST_ASSET_NAME] + if not manifests: + raise LifecycleError("RELEASE_MANIFEST_MISSING", + f"release {identity} carries no {MANIFEST_ASSET_NAME}") + if len(manifests) > 1: + raise LifecycleError("RELEASE_MANIFEST_AMBIGUOUS", + f"release {identity} carries {len(manifests)} " + f"{MANIFEST_ASSET_NAME} files") + entry = manifests[0] + digest = entry.get("file_sha256") + if not isinstance(digest, str) or not digest.strip(): + raise LifecycleError( + "RELEASE_INTEGRITY_METADATA_MISSING", + f"package file of release {identity} publishes no file_sha256") + cleaned = digest.strip().lower() + if len(cleaned) != 64 or any(character not in "0123456789abcdef" + for character in cleaned): + raise LifecycleError( + "RELEASE_INTEGRITY_METADATA_INVALID", + f"package file of release {identity} carries a malformed file_sha256") + size = entry.get("size") + if size is None: + raise LifecycleError( + "RELEASE_INTEGRITY_METADATA_MISSING", + f"package file of release {identity} publishes no size") + if isinstance(size, bool) or not isinstance(size, int) or size <= 0: + raise LifecycleError( + "RELEASE_INTEGRITY_METADATA_INVALID", + f"package file of release {identity} carries a malformed size") + return cleaned, size + + # --- fetching ------------------------------------------------------- + + def _download(self, identity: str, name: str, limit: int) -> bytes: + downloads = self._downloads.get(identity) + if downloads is None: + raise LifecycleError("UPDATE_CHECK_FAILED", + "the provider must enumerate before it fetches") + url = downloads.get(name) + if not url: + raise LifecycleError("UPDATE_UNAVAILABLE", + f"release {identity} publishes no package file " + f"{name!r}") + return self._get_bytes(url, limit) + + def fetch_manifest(self, candidate: release_v3lib.ReleaseCandidate) -> bytes: + return self._download(candidate.identity, MANIFEST_ASSET_NAME, + MAX_MANIFEST_BYTES) + + def fetch_artifact(self, candidate: release_v3lib.ReleaseCandidate, + artifact: release_v3lib.ManifestArtifact) -> bytes: + return self._download(candidate.identity, artifact.name, MAX_ARCHIVE_BYTES) + + __all__ = ["GitHubReleaseProvider", "AnonymousReleaseApiProvider", - "LocalReleaseProvider", "MANIFEST_ASSET_NAME", "MAX_MANIFEST_BYTES", - "MAX_ARCHIVE_BYTES", "MAX_ENUMERATION_ENTRIES"] + "LocalReleaseProvider", "GitLabReleaseProvider", "MANIFEST_ASSET_NAME", + "GITLAB_PACKAGE_NAME", "MAX_MANIFEST_BYTES", "MAX_ARCHIVE_BYTES", + "MAX_ENUMERATION_ENTRIES"] diff --git a/ainative/lifecycle/release_source.py b/ainative/lifecycle/release_source.py index c78fd2b4..7b3a4ed3 100644 --- a/ainative/lifecycle/release_source.py +++ b/ainative/lifecycle/release_source.py @@ -42,7 +42,12 @@ RESERVED_PROVIDERS = ("github", "gitlab", "local") CONFIG_SCHEMA_VERSION = 1 +GITLAB_API_BASE_URL = "https://gitlab.com/api/v4" +GITLAB_AUTH_ORIGIN = "https://gitlab.com" +GITLAB_TOKEN_ENV = "GITLAB_TOKEN" + KIND_GITHUB = "github" +KIND_GITLAB = "gitlab" KIND_LOCAL = "local" KIND_ANONYMOUS = "anonymous" KIND_NAMED = "named" @@ -58,6 +63,7 @@ class ReleaseSource: endpoint: transportlib.ReleaseProviderEndpointConfig | None = None directory: Path | None = None metadata_url: str | None = None + project_ref: str | None = None @property def authenticated(self) -> bool: @@ -69,6 +75,7 @@ def to_record(self) -> dict: "authenticated": self.authenticated, "auth_origin": self.endpoint.auth_origin if self.endpoint else None, "api_base_url": self.endpoint.api_base_url if self.endpoint else None, + "release_project_ref": self.project_ref, "directory": str(self.directory) if self.directory else None} @@ -100,7 +107,10 @@ def load_machine_config(home: Path | None = None) -> dict: raise LifecycleError("RELEASE_CONFIG_INVALID", f"{path}: providers must be an object") for name in providers: - if name in RESERVED_PROVIDERS: + # `gitlab` is a built-in provider that *is* configured here (its + # project reference has to live somewhere machine-scoped); the other + # built-ins cannot be redefined. + if name in RESERVED_PROVIDERS and name != "gitlab": raise LifecycleError( "RELEASE_CONFIG_INVALID", f"{path}: provider {name!r} is built in and cannot be redefined") @@ -130,6 +140,45 @@ def _named_source(name: str, entry: object) -> ReleaseSource: reason=f"named provider {name!r} (machine configuration)") +def _gitlab_source(entry: object) -> ReleaseSource: + """The built-in GitLab provider, configured per machine (ADR-0019 §61). + + The project reference has to live somewhere machine-scoped; it is a + numeric project id or a namespace path, never a URL. + """ + + if not isinstance(entry, dict): + raise LifecycleError( + "RELEASE_CONFIG_INVALID", + "provider 'gitlab' requires a machine configuration entry with " + "release_project_ref") + project_ref = entry.get("release_project_ref") + if not isinstance(project_ref, str) or not project_ref.strip(): + raise LifecycleError( + "RELEASE_CONFIG_INVALID", + "provider 'gitlab': release_project_ref is required (a numeric " + "project id or namespace/project)") + if project_ref.strip().lower().startswith(("http://", "https://")): + raise LifecycleError( + "RELEASE_CONFIG_INVALID", + "provider 'gitlab': release_project_ref is a project reference, " + "not a URL") + api_base = str(entry.get("api_base_url") or GITLAB_API_BASE_URL).strip() + if not api_base.lower().startswith("https://"): + raise LifecycleError("RELEASE_CONFIG_INVALID", + "provider 'gitlab': api_base_url must be an HTTPS URL") + origin = str(entry.get("auth_origin") or GITLAB_AUTH_ORIGIN).strip() + endpoint = transportlib.ReleaseProviderEndpointConfig( + provider="gitlab", api_base_url=api_base.rstrip("/"), auth_origin=origin, + api_version=str(entry.get("api_version") or "v4"), + credential_source=str(entry.get("credential_source") + or f"environment:{GITLAB_TOKEN_ENV}"), + auth_header="PRIVATE-TOKEN", auth_prefix="") + return ReleaseSource(kind=KIND_GITLAB, provider_name="gitlab", + endpoint=endpoint, project_ref=project_ref.strip(), + reason="gitlab provider (machine configuration)") + + def _builtin_github(reason: str) -> ReleaseSource: # Deferred: provider owns the default URL and imports this module inside # `build()`, so the import direction stays release_source -> provider. @@ -192,6 +241,8 @@ def _local_mirror(local_dir: str) -> ReleaseSource: def _selected_provider(provider: str, config: dict) -> ReleaseSource: if provider in ("github", "release-api"): return _builtin_github(f"built-in GitHub.com ({PROVIDER_ENV})") + if provider == "gitlab": + return _gitlab_source((config.get("providers") or {}).get("gitlab")) entry = (config.get("providers") or {}).get(provider) if entry is None: # Preserves the long-standing refusal for a name nothing declares; a @@ -214,6 +265,8 @@ def _default_source(config: dict) -> ReleaseSource: return ReleaseSource(kind=KIND_LOCAL, provider_name="local", directory=Path(directory).expanduser(), reason="machine default provider 'local'") + if default == "gitlab": + return _gitlab_source((config.get("providers") or {}).get("gitlab")) if default == "github": return _builtin_github("machine default provider 'github'") if default: @@ -248,5 +301,6 @@ def describe_lines(record: dict | None = None) -> list[str]: __all__ = ["ReleaseSource", "resolve_release_source", "load_machine_config", "config_path", "describe", "describe_lines", "PROVIDER_ENV", "LOCAL_SOURCE_ENV", "RELEASE_URL_ENV", "CONFIG_RELATIVE", - "RESERVED_PROVIDERS", "KIND_GITHUB", "KIND_LOCAL", "KIND_ANONYMOUS", - "KIND_NAMED"] + "RESERVED_PROVIDERS", "KIND_GITHUB", "KIND_GITLAB", "KIND_LOCAL", + "KIND_ANONYMOUS", "KIND_NAMED", "GITLAB_API_BASE_URL", + "GITLAB_AUTH_ORIGIN", "GITLAB_TOKEN_ENV"] diff --git a/ainative/lifecycle/release_v3.py b/ainative/lifecycle/release_v3.py index 7ab6b23a..92d74b5e 100644 --- a/ainative/lifecycle/release_v3.py +++ b/ainative/lifecycle/release_v3.py @@ -130,9 +130,15 @@ def to_record(self) -> dict: class ReleaseProvider: - """The V3 provider contract. Implementations fetch; this module decides.""" + """The V3 provider contract. Implementations fetch; this module decides. + + `supports_v2_fallback` says whether a complete channel without a V3 + candidate is a V2-era source (GitHub.com and mirrors are; GitLab is + V3-only and refuses instead). + """ name = "abstract" + supports_v2_fallback = True def enumerate(self, query: ReleaseQuery) -> EnumerationResult: raise NotImplementedError diff --git a/ainative/lifecycle/transport.py b/ainative/lifecycle/transport.py index 193491a7..30272b73 100644 --- a/ainative/lifecycle/transport.py +++ b/ainative/lifecycle/transport.py @@ -66,6 +66,10 @@ class ReleaseProviderEndpointConfig: where the credential comes from and is resolved by the provider, not here. An endpoint with `auth_origin=None` is anonymous by construction: no credential can be attached whatever the environment holds. + + `auth_header` and `auth_prefix` exist because providers disagree on the + shape of authentication: GitHub wants `Authorization: Bearer `, + GitLab wants `PRIVATE-TOKEN: `. The default is GitHub's. """ provider: str @@ -73,6 +77,8 @@ class ReleaseProviderEndpointConfig: auth_origin: str | None = None api_version: str = "" credential_source: str = "" + auth_header: str = "Authorization" + auth_prefix: str = "Bearer" GITHUB_ENDPOINT = ReleaseProviderEndpointConfig( @@ -136,7 +142,8 @@ def _request_headers(endpoint: ReleaseProviderEndpointConfig, origin: str, *, if endpoint.api_version: headers["X-GitHub-Api-Version"] = endpoint.api_version if token and _may_receive(endpoint, origin): - headers["Authorization"] = f"Bearer {token}" + value = f"{endpoint.auth_prefix} {token}".strip() if endpoint.auth_prefix else token + headers[endpoint.auth_header] = value return headers diff --git a/ainative/lifecycle/updater.py b/ainative/lifecycle/updater.py index 01d38c26..6072e5a1 100644 --- a/ainative/lifecycle/updater.py +++ b/ainative/lifecycle/updater.py @@ -236,6 +236,11 @@ def resolve_v3_candidate(channel: str): "the release source answered a full page; its enumeration bounds " "were reached before exhaustion") if not any(candidate.channel == channel for candidate in result.candidates): + if not provider.supports_v2_fallback: + raise LifecycleError( + "RELEASE_NO_CANDIDATE", + f"the {channel!r} channel of this source is complete and holds " + "no V3 release") return None candidate = release_v3lib.select_candidate(result, release_v3lib.ReleaseQuery(channel)) return candidate, provider diff --git a/templates/AGENTS.md b/templates/AGENTS.md index f895318d..2d357dfc 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -75,7 +75,7 @@ here rather than restating it. -- **The project's work authority is the canonical actionable backlog.** +- **The project's Work Authority is the canonical actionable backlog.** WorkItem state, review state and completion live there — never in this repository, never in the Vault. Skills = procedures; ADRs = accepted architecture; Work Contracts = deterministic verification when policy diff --git a/tests/purity/test_contract_purity.py b/tests/purity/test_contract_purity.py new file mode 100644 index 00000000..f1414101 --- /dev/null +++ b/tests/purity/test_contract_purity.py @@ -0,0 +1,68 @@ +"""Provider-neutral helpers behave identically for GitHub and GitLab (§74). + +The claim grammar, the Work Authority resolver and the release version policy +are parameterized by provider. Nothing may behave differently because the +provider word is `gitlab` instead of `github`: same identity shape, same +normalization, same selection rules. +""" + +from __future__ import annotations + +import unittest + +from ainative import claims +from ainative import forge as forgelib +from ainative.lifecycle import release_v3 +from ainative.lifecycle.errors import LifecycleError + +PROVIDERS = ("github", "gitlab") + + +class ContractPurity(unittest.TestCase): + + def test_canonical_identities_share_one_shape_across_providers(self): + for provider in PROVIDERS: + with self.subTest(provider=provider): + self.assertEqual(claims.principal(provider, "actor-1"), + f"{provider}:principal:actor-1") + self.assertEqual(claims.event_identifier(provider, "note", "42"), + f"{provider}:note:42") + self.assertEqual(claims.event_identifier(provider, "comment", "42"), + f"{provider}:comment:42") + + def test_remote_reading_understands_both_hosted_forges(self): + github = forgelib.ObservedRemote(name="origin", + url="git@github.com:org/project.git") + gitlab = forgelib.ObservedRemote(name="origin", + url="https://gitlab.com/org/sub/project.git") + self.assertEqual(github.provider, "github") + self.assertEqual(github.identity, "org/project") + self.assertEqual(gitlab.provider, "gitlab") + self.assertEqual(gitlab.identity, "org/sub/project") + for remote in (github, gitlab): + resolved = forgelib.resolve_observed_work_authority(remotes=(remote,)) + self.assertEqual(resolved.provider, remote.provider) + self.assertEqual(resolved.project, remote.identity) + + def test_the_version_policy_is_provider_independent(self): + for provider in PROVIDERS: + with self.subTest(provider=provider): + self.assertEqual(release_v3.canonical_version("v2.5.0"), "2.5.0") + with self.assertRaises(LifecycleError) as raised: + release_v3.canonical_version("2.5.0+build1") + self.assertEqual(raised.exception.code, + "RELEASE_BUILD_METADATA_UNSUPPORTED") + candidates = ( + release_v3.ReleaseCandidate(version="2.5.0", + identity=f"{provider}:v2.5.0"), + release_v3.ReleaseCandidate(version="2.5.0-rc.1", + identity=f"{provider}:v2.5.0-rc.1"), + ) + chosen = release_v3.select_candidate( + release_v3.EnumerationResult(candidates, complete=True), + release_v3.ReleaseQuery("stable")) + self.assertEqual(chosen.version, "2.5.0") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/purity/test_dependency_purity.py b/tests/purity/test_dependency_purity.py new file mode 100644 index 00000000..4c8279e6 --- /dev/null +++ b/tests/purity/test_dependency_purity.py @@ -0,0 +1,61 @@ +"""Neutral modules must not depend on provider implementations (ADR-0019 §74). + +`ainative.forge`, `ainative.claims` and `ainative.cli_support` are the neutral +layer: provider-neutral facts, identities and plumbing. They may import the +lifecycle's errors and state (neutral infrastructure), never a provider +implementation, the release transport, the updater or the observation module — +a neutral module that reaches a provider is a coupling that makes the neutral +layer untestable without a network or a forge. +""" + +from __future__ import annotations + +import ast +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] + +NEUTRAL_MODULES = ("ainative/forge.py", "ainative/claims.py", "ainative/cli_support.py") +BANNED_PREFIXES = ( + "ainative.lifecycle.provider", + "ainative.lifecycle.release_providers", + "ainative.lifecycle.transport", + "ainative.lifecycle.updater", + "ainative.lifecycle.release_source", + "ainative.observation", +) + + +def imported_modules(path: Path) -> set[str]: + """Every module name `path` imports, with relative imports resolved.""" + + tree = ast.parse(path.read_text(encoding="utf-8")) + found: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + found.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + if node.level: + package = "ainative" if path.parent.name == "ainative" \ + else "ainative.lifecycle" + found.add(f"{package}.{node.module}" if node.module else package) + elif node.module: + found.add(node.module) + return found + + +class DependencyPurity(unittest.TestCase): + + def test_neutral_modules_never_import_a_provider_or_the_transport(self): + for relative in NEUTRAL_MODULES: + with self.subTest(module=relative): + imports = imported_modules(REPO / relative) + offenders = {name for name in imports + if any(name == banned or name.startswith(banned + ".") + for banned in BANNED_PREFIXES)} + self.assertEqual(offenders, set(), relative) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/purity/test_distributed_policy_purity.py b/tests/purity/test_distributed_policy_purity.py new file mode 100644 index 00000000..c3a608a6 --- /dev/null +++ b/tests/purity/test_distributed_policy_purity.py @@ -0,0 +1,49 @@ +"""The distributed policy speaks provider-neutral Work Authority (§74). + +`templates/AGENTS.md` is what every project receives; the repository's own +`AGENTS.md` may stay GitHub-specific. The distributed file may name the +mappings, but it must not carry GitHub-only *authority*: no GitHub-only claim +procedure, no GitHub-only credential, no GitHub-only canonical-backlog +statement. +""" + +from __future__ import annotations + +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +DISTRIBUTED = REPO / "templates" / "AGENTS.md" + +FORBIDDEN_AUTHORITY = ( + "GitHub Issues are the canonical actionable backlog", + "docs/GITHUB-WORKFLOW.md", + "GITHUB_TOKEN", + "GH_TOKEN", +) + + +class DistributedPolicyPurity(unittest.TestCase): + + def setUp(self) -> None: + self.text = DISTRIBUTED.read_text(encoding="utf-8") + + def test_no_github_only_authority_survives_in_the_distributed_policy(self): + for phrase in FORBIDDEN_AUTHORITY: + with self.subTest(phrase=phrase): + self.assertNotIn(phrase, self.text) + + def test_the_distributed_policy_names_the_neutral_rules(self): + self.assertIn("Work Authority", self.text) + self.assertIn("WorkItem", self.text) + self.assertIn("FORGE-WORKFLOW.md", self.text) + self.assertIn("ainative claim-attempt", self.text) + self.assertIn("ACTIVE_PR_CONFLICT", self.text) + + def test_the_repository_own_policy_may_stay_github_specific(self): + own = (REPO / "AGENTS.md").read_text(encoding="utf-8") + self.assertIn("GitHub work management", own) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_providers.py b/tests/test_release_providers.py index d78abc2b..7851604b 100644 --- a/tests/test_release_providers.py +++ b/tests/test_release_providers.py @@ -46,8 +46,11 @@ def __init__(self, hops: dict) -> None: def send(self, request: urllib.request.Request): url = request.full_url - self.observed.append((url, request.get_header("Authorization"), - request.get_header("Accept"))) + headers = {key.lower(): value for key, value in request.headers.items()} + self.observed.append((url, + headers.get("authorization") + or headers.get("private-token"), + headers.get("accept"))) if url not in self.hops: raise AssertionError(f"unexpected request: {url}") answer = self.hops[url] @@ -320,5 +323,162 @@ def test_a_missing_artifact_is_refused(self): self.assertEqual(raised.exception.code, "UPDATE_UNAVAILABLE") +GITLAB_ENDPOINT = transportlib.ReleaseProviderEndpointConfig( + provider="gitlab", api_base_url="https://gitlab.com/api/v4", + auth_origin="https://gitlab.com", auth_header="PRIVATE-TOKEN", auth_prefix="") + +RELEASES_URL = "https://gitlab.com/api/v4/projects/group%2Fproject/releases?per_page=30" +PACKAGES_URL = ("https://gitlab.com/api/v4/projects/group%2Fproject/packages" + "?package_type=generic&package_name=ai-native-dev-stack&per_page=30") +MANIFEST_DOWNLOAD = ("https://gitlab.com/api/v4/projects/group%2Fproject/packages/77" + "/package_files/101/download") +BUNDLE_DOWNLOAD = ("https://gitlab.com/api/v4/projects/group%2Fproject/packages/77" + "/package_files/102/download") + + +def package_files(manifest_bytes: bytes, *, duplicate_manifest: bool = False, + digest: bool = True, digest_value: str | None = None, + size: bool = True) -> list: + manifest_entry = {"id": 101, "file_name": "ainative-release-v3.json"} + if digest: + manifest_entry["file_sha256"] = digest_value or sha256(manifest_bytes).hexdigest() + if size: + manifest_entry["size"] = len(manifest_bytes) + files = [manifest_entry, {"id": 102, "file_name": release_v3lib.lifecycle_bundle_name("2.5.0"), + "file_sha256": "b" * 64, "size": 20}] + if duplicate_manifest: + files.append({"id": 103, "file_name": "ainative-release-v3.json", + "file_sha256": "c" * 64, "size": 10}) + return files + + +class GitLabProvider(unittest.TestCase): + + def server(self, *, files: list | None = None, releases: list | None = None, + packages: list | None = None, manifest: bytes | None = None, + page_bound: int = 30) -> ScriptedServer: + manifest = manifest if manifest is not None else anchored_manifest() + releases_url = (f"https://gitlab.com/api/v4/projects/group%2Fproject/releases" + f"?per_page={page_bound}") + packages_url = ("https://gitlab.com/api/v4/projects/group%2Fproject/packages" + f"?package_type=generic&package_name=ai-native-dev-stack" + f"&per_page={page_bound}") + files_url = ("https://gitlab.com/api/v4/projects/group%2Fproject/packages/77" + f"/package_files?per_page={page_bound}") + hops = { + releases_url: json.dumps(releases if releases is not None else [ + {"tag_name": "v2.6.0", "upcoming_release": True}, + {"tag_name": "v2.5.0", "upcoming_release": False}, + {"tag_name": "nightly", "upcoming_release": False}, + ]).encode("utf-8"), + packages_url: json.dumps(packages if packages is not None else [ + {"id": 77, "package_type": "generic", "name": "ai-native-dev-stack", + "version": "2.5.0"}, + {"id": 78, "package_type": "generic", "name": "other", "version": "9.9.9"}, + ]).encode("utf-8"), + files_url: json.dumps(files if files is not None else package_files(manifest) + ).encode("utf-8"), + MANIFEST_DOWNLOAD: manifest, + BUNDLE_DOWNLOAD: b"bundle-bytes", + } + return ScriptedServer(hops) + + def provider(self, server: ScriptedServer, **kwargs): + return providerslib.GitLabReleaseProvider(GITLAB_ENDPOINT, "group/project", **kwargs) + + def test_enumeration_matches_release_and_package_identities(self): + server = self.server() + provider = self.provider(server) + with mock.patch.object(transportlib, "_send", server.send): + result = provider.enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertTrue(result.complete) + self.assertEqual([candidate.version for candidate in result.candidates], ["2.5.0"]) + candidate = result.candidates[0] + self.assertEqual(candidate.identity, "v2.5.0") + self.assertEqual(candidate.channel, "stable") + self.assertEqual(candidate.manifest_size, len(anchored_manifest())) + + def test_the_token_travels_as_a_private_token_only_at_the_origin(self): + server = self.server() + provider = self.provider(server) + with mock.patch.object(transportlib, "_send", server.send), \ + mock.patch.dict("os.environ", {"GITLAB_TOKEN": "glpat-" + "x" * 20}): + provider.enumerate(release_v3lib.ReleaseQuery("stable")) + headers = {url: token for url, token, _accept in server.observed} + self.assertEqual(headers[RELEASES_URL], "glpat-" + "x" * 20) + + def test_an_anonymous_endpoint_wears_no_gitlab_token(self): + endpoint = transportlib.anonymous_endpoint("https://mirror.example/api/v4") + provider = providerslib.GitLabReleaseProvider(endpoint, "group/project") + server = ScriptedServer({}) + server.hops = {RELEASES_URL.replace("https://gitlab.com/api/v4", "https://mirror.example/api/v4"): b"[]", + PACKAGES_URL.replace("https://gitlab.com/api/v4", "https://mirror.example/api/v4"): b"[]"} + with mock.patch.object(transportlib, "_send", server.send), \ + mock.patch.dict("os.environ", {"GITLAB_TOKEN": "glpat-" + "x" * 20}): + provider.enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertEqual([token for _url, token, _a in server.observed], [None, None]) + + def test_a_duplicate_package_for_one_version_is_refused(self): + server = self.server(packages=[ + {"id": 77, "package_type": "generic", "name": "ai-native-dev-stack", + "version": "2.5.0"}, + {"id": 78, "package_type": "generic", "name": "ai-native-dev-stack", + "version": "2.5.0"}]) + provider = self.provider(server) + with mock.patch.object(transportlib, "_send", server.send): + with self.assertRaises(LifecycleError) as raised: + provider.enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertEqual(raised.exception.code, "RELEASE_DUPLICATE_VERSION") + + def test_manifest_lookup_refusals(self): + cases = ( + (package_files(anchored_manifest())[1:], "RELEASE_MANIFEST_MISSING"), + (package_files(anchored_manifest(), duplicate_manifest=True), + "RELEASE_MANIFEST_AMBIGUOUS"), + (package_files(anchored_manifest(), digest=False), + "RELEASE_INTEGRITY_METADATA_MISSING"), + (package_files(anchored_manifest(), digest_value="nope"), + "RELEASE_INTEGRITY_METADATA_INVALID"), + (package_files(anchored_manifest(), size=False), + "RELEASE_INTEGRITY_METADATA_MISSING"), + ) + for files, code in cases: + with self.subTest(code=code): + server = self.server(files=files) + provider = self.provider(server) + with mock.patch.object(transportlib, "_send", server.send): + with self.assertRaises(LifecycleError) as raised: + provider.enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertEqual(raised.exception.code, code) + + def test_a_full_release_page_is_an_incomplete_enumeration(self): + server = self.server(releases=[{"tag_name": "v2.5.0", "upcoming_release": False}, + {"tag_name": "v2.4.0", "upcoming_release": False}, + {"tag_name": "v2.3.0", "upcoming_release": False}], + page_bound=3) + provider = self.provider(server, page_bound=3) + with mock.patch.object(transportlib, "_send", server.send): + result = provider.enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertFalse(result.complete) + + def test_the_whole_chain_runs_against_a_scripted_gitlab(self): + manifest = anchored_manifest() + server = self.server(manifest=manifest) + provider = self.provider(server) + with mock.patch.object(transportlib, "_send", server.send): + candidate, parsed = release_v3lib.resolve_manifest( + provider, release_v3lib.ReleaseQuery("stable")) + payload = provider.fetch_artifact(candidate, parsed.lifecycle_artifact()) + self.assertEqual(candidate.version, "2.5.0") + self.assertEqual(payload, b"bundle-bytes") + + def test_fetching_before_enumerating_is_a_programming_refusal(self): + provider = providerslib.GitLabReleaseProvider(GITLAB_ENDPOINT, "group/project") + with self.assertRaises(LifecycleError) as raised: + provider.fetch_manifest(release_v3lib.ReleaseCandidate( + version="2.5.0", identity="v2.5.0")) + self.assertEqual(raised.exception.code, "UPDATE_CHECK_FAILED") + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_release_source.py b/tests/test_release_source.py index b3008ec5..a7f9f48c 100644 --- a/tests/test_release_source.py +++ b/tests/test_release_source.py @@ -127,6 +127,32 @@ def test_a_reserved_name_cannot_be_redefined(self): sourcelib.resolve_release_source(environ={}, home=self.home) self.assertEqual(raised.exception.code, "RELEASE_CONFIG_INVALID") + def test_the_gitlab_provider_requires_a_project_reference(self): + with self.assertRaises(LifecycleError) as raised: + sourcelib.resolve_release_source( + environ={"AINATIVE_UPDATE_PROVIDER": "gitlab"}, home=self.home) + self.assertEqual(raised.exception.code, "RELEASE_CONFIG_INVALID") + + def test_the_gitlab_provider_resolves_with_its_configured_project(self): + self.write_config({"schema_version": 1, "providers": { + "gitlab": {"release_project_ref": "group/sub/project"}}}) + source = sourcelib.resolve_release_source( + environ={"AINATIVE_UPDATE_PROVIDER": "gitlab"}, home=self.home) + self.assertEqual(source.kind, sourcelib.KIND_GITLAB) + self.assertEqual(source.project_ref, "group/sub/project") + self.assertTrue(source.authenticated) + self.assertEqual(source.endpoint.auth_header, "PRIVATE-TOKEN") + self.assertEqual(source.endpoint.auth_origin, "https://gitlab.com") + self.assertEqual(source.endpoint.api_base_url, "https://gitlab.com/api/v4") + + def test_a_gitlab_project_reference_is_not_a_url(self): + self.write_config({"schema_version": 1, "providers": { + "gitlab": {"release_project_ref": "https://gitlab.com/group/project"}}}) + with self.assertRaises(LifecycleError) as raised: + sourcelib.resolve_release_source( + environ={"AINATIVE_UPDATE_PROVIDER": "gitlab"}, home=self.home) + self.assertEqual(raised.exception.code, "RELEASE_CONFIG_INVALID") + def test_a_custom_auth_origin_is_refused_in_v1(self): self.write_config({"schema_version": 1, "providers": { "internal": {"api_base_url": "https://x.example/api", From 223c7e48d09cc679cfa4ef9daf9fdf5e422b3226 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Wed, 16 Sep 2026 21:32:06 +0200 Subject: [PATCH 13/14] docs(multiforge): support matrix, qualification report and READMEs (#165, #166) --- CHANGELOG.md | 22 +++++++ README.fr.md | 22 +++++++ README.md | 20 +++++++ SUPPORT.md | 3 + docs/MULTIFORGE-QUALIFICATION.md | 100 +++++++++++++++++++++++++++++++ 5 files changed, 167 insertions(+) create mode 100644 docs/MULTIFORGE-QUALIFICATION.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ef79b32..ffe6c2fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,28 @@ project adheres to [Semantic Versioning](https://semver.org/). V2 records `active_features`; a V1 state projects to the compatibility default and migrates inside the next mutation without resurrecting an absent managed file. +- **Multi-Forge release sources and Release V3** (#165, ADR-0019): one + `resolve_release_source()` with fail-closed selector conflicts and a + machine-scope `~/.ai-native/release-providers.json`; a V3 release manifest + whose SHA-256 and size are verified (from provider metadata) *before* the + document is parsed; an exact version chain across candidate, manifest, + runtime, artifact, filename and the lifecycle protocol document; bounded + enumeration; SemVer without build metadata; duplicates refused. GitHub.com, + anonymous and local providers are wired into `update`/`update check` + (V2-era sources keep working), and `status`/`doctor` show the effective + source, its reason and whether it is authenticated. +- **GitLab provider (Release V3)** (#166): Releases for discovery, the + Generic Package Registry as the canonical integrity surface (`file_sha256` + + `size`), exact package lookup, bounded pagination, and `PRIVATE-TOKEN` + confined to the configured origin. GitLab.com live qualification is + UNTESTED and therefore not declared supported — + `docs/MULTIFORGE-QUALIFICATION.md` states exactly what was verified. +- **Work Authority observation and claims** (#163/#164, ADR-0018): + `ainative forge detect | status` (zero network, credentials, writes or + persistent trust), the doctor extension, the pure local resolver, the + canonical claim grammar and the durable + `ainative claim-attempt list | inspect | abandon` journal — written before + the remote signal, never retried blindly. ### Security diff --git a/README.fr.md b/README.fr.md index f69bd472..87b78450 100644 --- a/README.fr.md +++ b/README.fr.md @@ -577,6 +577,28 @@ ainative doctor # inclut la section Knowledge - Guides : docs/knowledge/KNOWLEDGE-CONVERGENCE-MATRIX.md et docs/knowledge/K1-K4-MEASUREMENT-GATE.md +## Multi-Forge + +Generic Git est le produit de base ; GitHub et GitLab sont des capacités +optionnelles, et le profil de gouvernance est indépendant d'elles +(ADR-0017/0018/0019). + +```bash +ainative feature status # l'ensemble effectif de features +ainative feature switch forge-gitlab # une transaction : GitHub -> GitLab +ainative feature switch none # Generic Git : aucune forge de travail +ainative forge status # remotes observés + résolution Work Authority +ainative claim-attempt list # tentatives de claim journalisées (recovery) +``` + +Les sources de release sont résolues par une seule fonction aux sélecteurs +fail-closed (GitHub.com par défaut ; un miroir local ; une +`AINATIVE_UPDATE_URL` anonyme ; GitLab.com via un `release-providers.json` de +portée machine). La gestion du travail est provider-neutre +(`docs/FORGE-WORKFLOW.md`, avec les mappings GitHub et GitLab) ; la chaîne V3 +vérifie un manifeste ancré de l'extérieur avant tout parsing, et la matrice de +support est dans `SUPPORT.md`. + ## Démarrage rapide ### Installer sur un projet existant diff --git a/README.md b/README.md index ccd72655..87215409 100644 --- a/README.md +++ b/README.md @@ -578,6 +578,26 @@ ainative doctor # includes the Knowledge section - Guides: docs/knowledge/KNOWLEDGE-CONVERGENCE-MATRIX.md and docs/knowledge/K1-K4-MEASUREMENT-GATE.md +## Multi-Forge + +Generic Git is the base product; GitHub and GitLab are optional capabilities, +and the governance profile is independent of them (ADR-0017/0018/0019). + +```bash +ainative feature status # the effective feature set +ainative feature switch forge-gitlab # one transaction: GitHub -> GitLab +ainative feature switch none # Generic Git: no work forge +ainative forge status # observed remotes + Work Authority resolution +ainative claim-attempt list # journaled claim attempts (recovery) +``` + +Release sources are resolved by one function with fail-closed selectors +(GitHub.com by default; a local mirror; an anonymous `AINATIVE_UPDATE_URL`; +GitLab.com through a machine-scoped `release-providers.json`). Work +management is provider-neutral (`docs/FORGE-WORKFLOW.md`, with the GitHub and +GitLab mappings); the V3 release chain verifies an externally anchored +manifest before parsing anything, and the support matrix is in `SUPPORT.md`. + ## Quick Start ### For an existing project diff --git a/SUPPORT.md b/SUPPORT.md index 02f97326..b5da57b4 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -9,6 +9,9 @@ | Operating systems | Linux, macOS and Windows (CI exercises all three) | | Profiles | Standard; Verified (adds governed Work Contracts and deterministic verification) | | Harnesses | Claude Code, Codex, OpenCode, Cursor, Gemini CLI, MiniMax/Mavis — for the shared method, skills and hooks | +| Work management | Generic Git; GitHub (mapping); GitLab (mapping). The Work Authority is resolved locally, purely and fail-closed (ADR-0018) | +| Features | Project-scope features independent of the profile (`forge-github` default, `forge-gitlab`), State schema V2 (ADR-0017) | +| Release sources | GitHub.com (built-in, qualified); a local mirror; an anonymous HTTPS release API (`AINATIVE_UPDATE_URL`, never credentialed); GitLab.com provider implemented and contract-tested, live-service qualification UNTESTED — **not declared supported**; GitLab Self-Managed and GitHub Enterprise Server UNTESTED — **not supported** | Only the latest release is supported; fixes ship as a new patch version. diff --git a/docs/MULTIFORGE-QUALIFICATION.md b/docs/MULTIFORGE-QUALIFICATION.md new file mode 100644 index 00000000..4b97e19a --- /dev/null +++ b/docs/MULTIFORGE-QUALIFICATION.md @@ -0,0 +1,100 @@ +# Multi-Forge qualification report + +**Base commit:** `multiforge` head `6f4946b` plus the documentation commit that +carries this report. **Architecture:** frozen (Multi-Forge v1.3.2-final; +ADR-0017/0018/0019). **Execution:** local, issue-driven; the full CI matrix +runs when the branch is proposed for `dev`. + +This report states what was verified, with which evidence, and what was not. +Declared support never exceeds qualified support (`SUPPORT.md`, ADR-0019 §13). + +## What was delivered + +| Stream | Deliverable | Evidence | +|---|---|---| +| PR-0A | Release transport credential confinement (`transport.py`) | 23 tests; characterization probe recorded pre-fix | +| ADR-MF-01/02/03 | ADR-0017/0018/0019 accepted | merged documents | +| PR-0B | V2 forward bridge (`CLI_UPDATE_REQUIRED`) + publication gate | 5 tests; `scripts/check_bridge_release.py` + 9 gate tests | +| PR-1 | Feature model, State V2, atomic switching, GitLab templates | 34 tests + 309-suite regression | +| PR-2 | Work Authority resolver, claim grammar, journal, policy docs, distributed template | 27 tests; policy purity tests | +| PR-3 | `forge detect/status`, doctor extension, status parity | 7 observation tests | +| PR-4 | Source resolver, ReleaseManifest V3, providers, updater wiring | 19 + 32 + 16 + 5 tests | +| PR-5 | GitLab provider (V3 contract), purity tests | 10 provider tests + 7 purity tests | + +## Platform matrix + +| Platform | Status | +|---|---| +| Windows (local) | GREEN — the full local suite (314 lifecycle + CLI/knowledge/machine suites) | +| Linux, macOS | PENDING — CI runs on the `multiforge` → `dev` proposal; not yet executed | + +## Forge qualification + +| Tuple | Status | +|---|---| +| Generic Git | GREEN — `feature switch none`, zero-feature states valid, observation resolves `UNAVAILABLE` | +| GitHub.com | GREEN for the release source (existing qualified releases + provider contract tests); GREEN for work management (mapping + observation) | +| GitLab.com | Provider implemented and contract-tested against a scripted GitLab API. **Live-service qualification UNTESTED — not declared supported** | +| GitLab Self-Managed | UNTESTED — not supported | +| GitHub Enterprise Server | UNTESTED — not supported | + +## E2E scenarios A–Q (plan §76) + +| # | Scenario | Evidence | Status | +|---|---|---|---| +| A | Generic Git | `test_lifecycle_features` (switch none, zero features), `test_forge_claims` (UNAVAILABLE) | GREEN | +| B | New GitLab project | feature switch to `forge-gitlab` + GitLab provider tests (scripted) | PARTIAL — no live GitLab project exists to qualify | +| C | Legacy GitHub project | V1 projection + migration tests | GREEN | +| D | GitHub → GitLab → none → GitHub | `test_a_round_trip_loses_no_user_data` | GREEN | +| E | Fork origin/upstream ambiguity | resolver + `forge detect` rendering (AMBIGUOUS, exit 0) | GREEN | +| F | Credential exfiltration attack | PR-0A tests (evil URL, artifact URL, gate: zero credentials at non-approved origins) | GREEN | +| G | Private GitHub asset → anonymous CDN | asset API flow test (scripted transport) | GREEN (scripted) | +| H | Tampered ReleaseManifest V3 | `test_release_v3` + `test_lifecycle_update_v3` (zero writes) | GREEN | +| I | Duplicate GitLab package/manifest | provider tests (`RELEASE_DUPLICATE_VERSION`, `RELEASE_MANIFEST_AMBIGUOUS`) | GREEN | +| J | GitLab object-storage blob | shared transport policy test (API → CDN strips credentials); no GitLab-specific 302 fixture yet | PARTIAL | +| K | Claim crash after POST | journal tests (`UNCERTAIN` unresolved, no retry, explicit `abandon`) | GREEN | +| L | Planner refusal during V1 projection | two-work-forge state refuses; projection never writes | GREEN | +| M | Legacy GitLab remote keeps GitHub compatibility default | projection test; the *warning* surfacing is not implemented | PARTIAL | +| N | Read-only V1 projection == persisted V2 | migration parity test + `status` parity test | GREEN | +| O | Missing legacy managed template not resurrected | `test_the_migration_does_not_resurrect_an_absent_template` | GREEN | +| P | Selector conflict identical across commands | one resolver consumed by update/check/status/doctor; conflicts tested at the resolver; no single cross-command E2E test | PARTIAL | +| Q | Mandatory secret patterns survive operator configuration | **not implemented** — the anti-debt scanner contract (`extra_secret_patterns`) is still pending | PENDING | + +## Security, migration, claims, integrity + +- **Transport:** zero provider credentials observed at non-approved origins + across the metadata + artifact flow; `https → http` downgrades, userinfo URLs + and over-long redirect chains refused; `AINATIVE_UPDATE_URL` anonymous. +- **State migration:** V1→V2 inside the next mutation, state-last, idempotent; + absent managed files stay absent; two-work-forge states refuse. +- **Claims:** journal written durably before the remote signal; corrupt or + unwritable journals fail closed; an uncertain POST is never retried; + abandonment is an explicit operator transition that keeps the record. +- **Release integrity:** the manifest is parsed only after its external anchor + (SHA-256 + size from provider metadata) is verified; the exact version chain + holds across six identities; enumeration incompleteness and duplicates + refuse instead of resolving by order. + +## Known limitations and remaining work + +**P1 — blocks the declared-support gate:** +- GitLab.com live qualification (requires a real GitLab project publishing + `ai-native-dev-stack` generic packages and an `ainative-release-v3.json` + manifest). +- Scenario Q: mandatory secret patterns (`MANDATORY_SECRET_PATTERNS` ∪ + operator extras) and the GitLab token-prefix qualification tuple. +- Full CI matrix (Linux/Windows/macOS, py3.11/3.13) — runs on the `dev` + proposal. +- EN/FR documentation parity automation (plan §75). + +**P2 — known gaps, documented rather than hidden:** +- Scenario J: no GitLab-specific object-storage 302 fixture (the shared + transport policy covers the behavior). +- Scenario M: the "legacy GitLab remote" case projects correctly but no + warning line is rendered yet. +- Scenario P: no single cross-command selector-conflict E2E test. +- The V2 bridge release and the V3 release have not been published: the plan's + release choreography (§92) starts after the branch is promoted to `dev`, and + the first V3 publication stays gated by `V3_BRIDGE_RELEASE`. + +**P0:** none known. From 155ac7bffbb7d91f1fafcd07e8b9e012fadfe20d Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Wed, 16 Sep 2026 21:40:07 +0200 Subject: [PATCH 14/14] fix(e2e): follow the distributed AGENTS.md template in the upgrade E2E (#165) --- scripts/lifecycle_upgrade_e2e.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/scripts/lifecycle_upgrade_e2e.py b/scripts/lifecycle_upgrade_e2e.py index 311d66cb..fb0893b6 100644 --- a/scripts/lifecycle_upgrade_e2e.py +++ b/scripts/lifecycle_upgrade_e2e.py @@ -123,11 +123,16 @@ def relabel_tree(source_root: Path, destination: Path, version: str) -> Path: start = text.index(marker) + len(marker) end = text.index('"', start) init.write_text(text[:start] + version + text[end:], encoding="utf-8") - agents = destination / "AGENTS.md" - text = agents.read_text(encoding="utf-8") - relabelled = re.sub(r"(stack-version:\s*)[0-9][0-9A-Za-z.\-]*", - rf"\g<1>{version}", text, count=1) - agents.write_text(relabelled, encoding="utf-8") + # Both policy files carry the version label: the repository's own AGENTS.md + # and the distributed template the engineering-method component installs + # (ADR-0018). Relabelling only one of them left the managed payload + # byte-identical between N and N+1, and the update plan became a no-op. + for relative in ("AGENTS.md", "templates/AGENTS.md"): + agents = destination / relative + text = agents.read_text(encoding="utf-8") + relabelled = re.sub(r"(stack-version:\s*)[0-9][0-9A-Za-z.\-]*", + rf"\g<1>{version}", text, count=1) + agents.write_text(relabelled, encoding="utf-8") return destination @@ -258,7 +263,9 @@ def main() -> int: if state["stack_version"] != to_version or state["source_version"] != to_version: raise Failure(f"update did not land: {state['stack_version']}/" f"{state['source_version']}, expected {to_version}") - expected_agents = (probe_tree / "AGENTS.md").read_bytes() + # The engineering-method component installs the distributed template + # (ADR-0018), not the repository's own AGENTS.md. + expected_agents = (probe_tree / "templates" / "AGENTS.md").read_bytes() if (project / "AGENTS.md").read_bytes() != expected_agents: raise Failure("project assets are not the probe release's assets") ok(f"project updated to {to_version}: state, source_version and assets agree")