Skip to content

feat: Multi-Forge implementation (features, claims, Release V3, GitLab provider) -> dev - #172

Merged
Rwanbt merged 14 commits into
devfrom
multiforge
Sep 16, 2026
Merged

Rwanbt merged 14 commits into
devfrom
multiforge

Conversation

@Rwanbt

@Rwanbt Rwanbt commented Sep 16, 2026

Copy link
Copy Markdown
Owner

What this brings

The complete Multi-Forge implementation (frozen architecture: Multi-Forge
v1.3.2-final; ADR-0017/0018/0019), on multiforge, proposed for dev — not
main (per the maintainer instruction: main is untouched until the full
implementation is tested and integrated).

13 commits, ~7 000 changed lines, 314 lifecycle tests + 221 other suites all
green locally.
docs/MULTIFORGE-QUALIFICATION.md states exactly what was
verified and what remains.

Stream Deliverable
PR-0A Release transport credential confinement (PR-0A's issue was #158; already on dev from the earlier wave)
PR-1 Feature model, State V2, atomic switching, GitLab templates (34 tests)
PR-2 Work Authority resolver, claim grammar + durable journal, provider-neutral policy docs, distributed AGENTS.md template (27 tests)
PR-3 `ainative forge detect
PR-4 One source resolver, ReleaseManifest V3 with external anchor + exact version chain, GitHub/anonymous/local providers, updater wired to V3 with a V2-era fallback (19+32+16+5 tests)
PR-5 GitLab provider on the Generic Package Registry contract, per-endpoint auth scheme, purity suites (10+7 tests)

Scope

  • New modules: transport hardening extensions, release_source,
    release_v3, release_providers, features, feature_cli, cli_support,
    forge, claims, claim_cli, observation, forge_cli, tests/purity/*.
  • New refusal codes: release V3 family, UPDATE_SOURCE_CONFLICT,
    RELEASE_CONFIG_INVALID, feature/claim/authority families.
  • Docs: docs/FORGE-WORKFLOW.md (neutral policy), docs/GITLAB-WORKFLOW.md,
    refactored docs/GITHUB-WORKFLOW.md, docs/MULTIFORGE-QUALIFICATION.md,
    templates/AGENTS.md (provider-neutral distributed policy), READMEs,
    SUPPORT.md, CHANGELOG.

Non-scope

  • No universal Forge SDK, no remote work API clients, no claim leases, no
    second lifecycle transaction system.
  • GHES / GitLab Self-Managed: not declared supported (UNTESTED).

Verification

  • Local (Windows): 314 lifecycle tests, 221 CLI/knowledge/machine/release/purity
    suites — all green; gates green (conventions, scope, complexity 0 findings,
    LOC 0 warnings).
  • This PR triggers the full CI matrix (Linux/Windows/macOS, py3.11/3.13) — the
    "CI at the end" run requested by the maintainer.
  • Known remaining items (documented, not hidden): GitLab.com live
    qualification (no live project available), scenario Q mandatory secret
    patterns (anti-debt scanner contract), EN/FR docs-parity automation, and two
    partial E2E scenarios (J, M, P) — see the qualification report.

Risk

  • Behavior: legacy GitHub-only projects keep the forge-github compatibility
    default and the V2 update path keeps working (fallback proven by the existing
    suite); selectors that used to be ignored now fail closed
    (AINATIVE_UPDATE_URL + provider/local → UPDATE_SOURCE_CONFLICT;
    AINATIVE_UPDATE_LOCAL_DIR alone → conflict) — documented as breaking.
  • Rollback: revert the squash; no persisted format migrations are forced
    (State V2 migrates state-last inside normal mutations), and no release has
    been published from this branch.

Release choreography after merge

Still pending by design: publish the V2 bridge release containing PR-0B, then
the first V3 publication is gated by V3_BRIDGE_RELEASE (plan §92). main is
not touched by this PR.

)

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
`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
…journal (#163)

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
  (`<provider>:principal:<id>`, `<provider>:<kind>:<id>`), 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
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.
…sion (#164)

`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
#165)

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
…on chain (#165)

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
#165)

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
…#165)

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
#166)

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: <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
@Rwanbt
Rwanbt merged commit f0101fc into dev Sep 16, 2026
52 checks passed
Rwanbt added a commit that referenced this pull request Sep 17, 2026
* fix(lifecycle): fail-closed release version chain and runtime freshness

A release could publish a v2.2.2 tag with a 2.2.1 bundle inside (AUD-201), and an old lifecycle runtime could silently apply a new stack it does not know (AUD-202).

- provider: accept only ainative-dev-stack-<release.version>.zip, official and mirror alike; a mismatch is UPDATE_VERSION_MISMATCH before any download
- updater: refuse a bundle whose internal VERSION differs from the release; require runtime == target with CLI_UPDATE_REQUIRED before download and before any write; refusals now write nothing, cache included
- status/doctor: surface runtime_ready so a user sees that the CLI must be upgraded first
- #131: every cache read reconciles a stale UPDATE_AVAILABLE notice against the live project version (UP_TO_DATE, fail-safe on malformed values), reads never rewrite the cache; rollback dry-run says would roll back and writes nothing
- tests: version-chain mutation cases A-G, runtime freshness, stale cache; new non-vacuity cases prove each guard blocks (39/39)

* ci(release): version gate, real upgrade E2E, SHA-pinned actions, supply-chain baseline

- release workflow refuses tag != v\2.2.2 and re-verifies every artifact (bundle filename+internal VERSION, wheel metadata, sdist PKG-INFO) through scripts/check_release_versions.py
- new upgrade-e2e job (Linux/Windows/macOS): scripts/lifecycle_upgrade_e2e.py builds two real wheels, installs N in a fresh venv, proves CLI_UPDATE_REQUIRED with zero writes, upgrades the runtime, applies N+1, rolls back, and refuses a tampered bundle
- all third-party Actions pinned to full commit SHAs; Dependabot for github-actions; SECURITY.md reporting path; CODEOWNERS for sensitive zones (#129)
- Python 3.8 matrix entry best-effort is now actually wired; orphan tests/mv00 Multi-Vault feasibility suites run in CI

* fix(anti-debt): fingerprint secrets instead of previewing them; fail loudly on parser defects

- scan_security: trufflehog/gitleaks findings carry sha256(secret)[:12], never raw[:8], in ids or evidence (AUD-203); stable for the same input, distinct across secrets
- scan_security/scan_deps/scan_code: a defect in a scanner's own parser is no longer converted into a warning entry (the clippy line_start class of #127); missing binaries, timeouts, non-zero exits and invalid external JSON still degrade to structured warnings
- AI_CONTEXT: execution model documented as shell=False with the controlled cmd /c shim exception
- tests: no-secret-fragment, stable/distinct fingerprint, parser-defect-propagates; CI step added

* docs(release): v2.2.2 - README EN/FR parity, lifecycle docs, CHANGELOG, version bump

- README EN and FR now describe the same operational facts: CLI-first upgrade then per-project update, the fail-closed release chain, Verified Work Plane, K5 STOP, Multi-Vault GUARDED; FR regains the missing components 13-17 and the Verified section
- UPDATING.md and docs/DISTRIBUTION-LIFECYCLE.md document CLI_UPDATE_REQUIRED, UPDATE_VERSION_MISMATCH and the zero-write refusal order
- K0-A capability probe flagged historical and pointed at the convergence matrix; K0-B index annotated
- CHANGELOG 2.2.2; VERSION/__init__/AGENTS.md/README pins bumped together; scope figures re-measured

* feat(lifecycle): first-run product readiness - hook wiring, doctor, git policy, path gate

- init now merges one owned PostToolUse entry into .claude/settings.json (json_hook component): user keys and groups preserved, idempotent, uninstall/rollback remove only the owned entry, unparsable JSON refused without rewriting
- doctor gains an environment section (python, git repository/HEAD, node, hook status, harness integration, vault, Obsidian API, graphify, machine manifest, trust anchor) with OK/ABSENT_OPTIONAL/DEGRADED/FAIL; missing automation fails doctor, optional tools do not
- Verified refuses a project that is not a Git repository (GIT_REPOSITORY_REQUIRED); Standard installs with an explicit degradation notice
- action vocabulary no longer lies: BLOCK_WRITE/BLOCK_REMOVE replaced by REGION_WRITE/REGION_REMOVE/HOOK_WRITE/HOOK_REMOVE in plans (legacy journal names still readable)
- Knowledge fresh-init fixed (#138): the managed .gitignore region carries .ai-native/state/, refusals print actionable remedies; knowledge state and trust anchor are owner-only on POSIX
- machine paths removed from distributed files (Mavis-generated opencode artifacts deleted, enforcement script defaults removed, personal email out of fixtures); scripts/check_personal_paths.py gates tracked files with a reasoned allowlist and a mutation test

* ci: run the hook, first-run policy and machine-path gates

* feat(workplane): trust init onboarding, schema validation, stable refusals

- trust_schema owns the approval-root/policy shapes: validation through contracts.validate_artifact (one structural owner) plus semantic coherence (self-commitments, predicate vocabulary, observable facts); codes TRUST_ROOT_INVALID, TRUST_POLICY_INVALID, TRUST_SCHEMA_UNSUPPORTED
- 'ainative trust init' scaffolds a minimal coherent pair and claims no authority; bootstrap remains the ceremony; predicate mismatch between policy and bootstrap is refused
- bootstrap validates before reading: {} no longer raises KeyError with exit 1 (NOT_CONVERGED's code); workplane refusals now print 'refused: CODE: message' on stderr and the same record as JSON on stdout, always exit 2
- scripts/verified_first_run_e2e.py: fresh wheel + venv + git project, full governed workflow to CONVERGED through the CLI only, plus the refusal battery; CI job on 3 OS; tests.test_trust_onboarding covers scaffold and refusals

* feat(lifecycle): versioned update protocol v2, authenticated checks, atomic release

- protocol v2: bundles are ainative-lifecycle-v2-<version>.zip with a lifecycle-protocol.json at the root and the payload under stack/; runtimes <= v2.2.2 cannot resolve it even from a mirror (layout containment, AUD-205), and v2.2.2 refuses at the runtime gate first
- updater validates the protocol document (version, release, payload root) before trusting the payload
- GITHUB_TOKEN/GH_TOKEN support for update checks: sent as a bearer header, never logged, never cached; 403/429 reported as a rate-limit diagnosis
- 'update check --strict' exits non-zero when the source could not be consulted; default documents exit 0 != reachable
- release workflow: draft -> upload (no clobber) -> verify names/sizes/digests -> publish, with GitHub build-provenance attestations; scripts/check_published_assets.py and the extended check_release_versions.py (--without-bundle for the PyPI half)
- new publish-pypi.yml using PyPI Trusted Publishing (OIDC); docs/RELEASING.md documents setup, verification and the no-clobber recovery path; tests for the transport, containment and both gates

* feat(machine): machine ownership manifest, safe uninstall, dogfood fix (#137, #18-#20)

- every global install now records ~/.ai-native/machine.json: each link, managed block and rendered file with its source or digest and the stack version
- scripts/machine_lifecycle.py owns the reversal rule: recorded+unmodified assets are removed (links/junctions are removed, never followed), user files and user-modified assets are preserved and reported, empty directories the uninstall created are pruned up to the standard harness roots, the manifest is deleted only after success
- install_agents.py --uninstall [--dry-run] prints the plan; dry runs are pure (tests caught the helpers mutating during classification); rendered files are written as bytes so their recorded digest matches the file on Windows
- lifecycle_dogfood.py force-adds .ai-native in its scratch clone (#137: the repo's own gitignore kept the anchor uncommitted, so verified_anchor counted zero commits); the first full dogfood run then caught a stale non-vacuity anchor, fixed
- scripts/tests/test_machine_lifecycle.py covers record, dry-run purity, user-file preservation, modified-asset preservation, idempotence and malformed manifests

* docs(release): v2.3.0 - support docs, machine lifecycle docs, executable first runs

- SUPPORT.md (support matrix, reporting path, known limitations) and CODE_OF_CONDUCT.md
- README EN/FR: Verified first run is trust init + bootstrap; the PostToolUse hook is configured by init, not by hand; machine integration records a manifest and has a real uninstall; non-git behaviour documented
- UPDATING.md and docs/DISTRIBUTION-LIFECYCLE.md: machine update/uninstall, the claude-hook component, protocol v2 containment
- version bump 2.3.0 across VERSION, package, AGENTS.md header and README pins; CHANGELOG; scope figures re-measured
- both E2E scripts now build from a hermetic copy of the tree: a developer's stale build/ cache made pip wheel fail with WinError 183 and could contaminate the artifact under test

* fix(lifecycle): managed files get umask modes; clean-install works in a Git project

- write_atomic/write_bytes_atomic apply the umask-compatible mode after replace: NamedTemporaryFile's 0600 leaked into every managed project file on POSIX (private state still calls write_atomic_private explicitly)
- lifecycle_clean_install.py initialises the fresh project as a Git repository (Verified refuses without one) and gains a non-Git leg proving the documented policy: Standard installs with a notice, Verified refuses with zero writes
- the non-Git leg isolates Git discovery with GIT_CEILING_DIRECTORIES so a developer's enclosing repository cannot change the result

* fix(clean-install): the payload/checkout comparison ignores the project's own .git

* fix(hooks): the PostToolUse wrapper reads stdin through the pipeline first

[Console]::In.ReadToEnd() returned empty on the Windows CI runners while \ held the payload (and the reverse was observed locally); whichever has the JSON wins, and a skip is reported on stderr instead of staying silent. The same fix is applied to the shared global hook wrapper.

* fix(hooks): the wrapper reports why it skipped instead of exiting silently

* fix(hooks): hand the payload to Python by file, with visible skips

PowerShell's pipe to a native executable is not byte-stable across host shapes (the console-less runners delivered an empty or mangled stream). The wrapper now tries pipeline, raw stdin handle and Console.In in turn, writes the exact bytes to a temp file, and calls update_on_edit.py --payload-file; the script reports an empty or non-JSON payload on stderr instead of skipping silently. Get-Command matches are enumerated (-All) so a WindowsApps Store alias can never win over a real interpreter, the global hook keeps its own stack-root resolution, and the test makes the running interpreter visible on PATH.

* fix(release): the published-asset report never lands inside dist

The v2.3.0 release workflow aborted after every asset had uploaded and attested: the verification step wrote its assets.json into dist/, and the checker counted it as an unbuilt extra asset. The report now lives in RUNNER_TEMP, the checker excludes its own report file defensively, and the regression is pinned by test.

* release: 2.4.2

* release: 2.4.3

Ships the #153 OpenCode plugin runtime fix and closes the published-artifact
gap it exposed: the staged payload carried no adapters/ tree, so a wheel install
rendered no plugin (visible SKIP) and the documented machine integration
existed for checkout users only.

- adapters/ (OpenCode + Claude Code) joins the wheel and sdist payload, so
  `ainative machine init` renders the plugin from the installed distribution.
- A durable runtime gate (the opencode-plugin CI job, inside the Production
  Gate) loads the rendered plugin in a real runtime with no global Bun, drives
  edit/write, refuses a file over the blocking limit, reads that limit from
  conventions.json, and proves AI_SUMMARY.md is regenerated.
- The same gate runs against the plugin rendered by a wheel installed in a
  fresh venv, so the published artifact is what is tested.
- Version labels move together (VERSION, ainative.__version__, AGENTS.md
  stack-version, scope table); the READMEs, UPDATING, RELEASING, PUBLIC-PILOT
  and the Multi-Vault operator guide pin v2.4.3.

* fix(opencode-e2e): compare resolved manifest paths, not Windows short names

* fix(lifecycle): confine release credentials to the configured origin (#158) (#167)

The release transport attached GITHUB_TOKEN/GH_TOKEN to every request it
made — including an artifact URL named by the metadata of a custom
AINATIVE_UPDATE_URL — and urllib's redirect handler copied Authorization to
any redirect target (both reproduced before changing production code).

A new transport module follows redirects itself, one policy-checked hop at a
time: release metadata may not leave its own origin, an artifact redirect is
followed anonymously with every credential recomputed per hop, https -> http
downgrades and URLs carrying userinfo are refused, HTTPS is required
throughout, and redirect chains are bounded at five. GitHub private release
assets are fetched through the release asset API URL
(Accept: application/octet-stream); the 302 to the CDN is stripped of
credentials. The credential origin is endpoint configuration (auth_origin),
never derived from the URL being fetched.

AINATIVE_UPDATE_URL is anonymous by construction: it can no longer inherit
the provider token. Documented as a breaking behavior change in the CHANGELOG
and the distribution lifecycle documentation.

Refs #158

* docs(adr): ADR-MF-02 - neutral workflow, work authority and claims (Refs #159) (#169)

* docs(adr): ADR-MF-03 - release source resolution, manifest V3, providers (Refs #161) (#170)

* docs(adr): ADR-MF-01 - Generic Git, features and state V2 (Refs #160) (#168)

* feat(lifecycle): report a future lifecycle protocol as CLI_UPDATE_REQUIRED (#157) (#171)

A V2 runtime meeting a V3 publication used to say
UPDATE_INTEGRITY_METADATA_MISSING - "no lifecycle bundle to verify" - which
sends every user looking for a publishing defect instead of upgrading the CLI.
The two situations are now distinct:

- a release that publishes a lifecycle bundle or manifest for a protocol newer
  than this runtime's (ainative-lifecycle-v3-*, ainative-release-v3.json)
  resolves as a refusal: CLI_UPDATE_REQUIRED with the upgrade command, and
  `update check` reports it as available with a CLI upgrade required;
- a release that publishes no lifecycle bundle at all keeps
  UPDATE_INTEGRITY_METADATA_MISSING, so a broken publication is never mistaken
  for future protocol.

`upgrade_command` moves to provider.py (a release-source refusal must name the
upgrade path, and provider must not import the updater); updater re-exports it.

Adds the bridge publication gate: scripts/check_bridge_release.py resolves a
declared V3_BRIDGE_RELEASE through the same V2 selection rules a user's updater
uses and blocks a V3 publication when the bridge is missing, unpublished, or
carries no lifecycle bundle. The release workflow runs it when the repository
variable V3_BRIDGE_RELEASE is set (explicit SKIP otherwise). The manual
stale-runtime escape path is documented in RELEASING.md and the distribution
lifecycle documentation.

Characterization: the new tests failed on the pre-change tree with
UPDATE_INTEGRITY_METADATA_MISSING / CHECK_FAILED; they pass after the change.

Refs #157

* feat: Multi-Forge implementation (features, claims, Release V3, GitLab provider) -> dev (#172)

* feat(lifecycle): feature model, State V2 and the shared projection (#162)

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

* feat(lifecycle): feature switching in one transaction (#162)

`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

* test(lifecycle): feature ownership matrix - a user file is never adopted (#162)

* feat(forge): pure work-authority resolver, claim grammar and durable journal (#163)

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
  (`<provider>:principal:<id>`, `<provider>:<kind>:<id>`), 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

* docs(workflow): provider-neutral FORGE-WORKFLOW, GitHub and GitLab mappings (#163)

* 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.

* feat(forge): forge detection, status observation and the doctor extension (#164)

`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

* feat(release): one source resolver with fail-closed selector conflicts (#165)

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

* feat(release): Release V3 contract, anchored manifest and exact version chain (#165)

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

* feat(release): V3 providers - GitHub, anonymous document, local mirror (#165)

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

* feat(release): wire the updater to the V3 flow with a V2-era fallback (#165)

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

* feat(release): GitLab provider, auth scheme per endpoint, purity tests (#166)

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: <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

* docs(multiforge): support matrix, qualification report and READMEs (#165, #166)

* fix(e2e): follow the distributed AGENTS.md template in the upgrade E2E (#165)

* feat(security): mandatory secret patterns as an unremovable floor, scenario closures (#166) (#174)

Closes the remaining traceable items from the Multi-Forge qualification report.

- Candidate scanner (ainative/multivault/git_scanner.py): the constructor now
  takes `extra_secret_patterns` and no longer accepts a replacement set;
  the effective patterns are `MANDATORY_SECRET_PATTERNS + extras`, so no
  caller, repository or operator configuration can remove, replace, disable or
  shadow the floor (ADR-0019 section 12). The floor gains the documented
  GitLab token prefixes (`glpat-`, `gldt-`, `glrt-`, `glsoat-`), alongside
  private-key markers, AWS, GitHub and Slack patterns. The anti-debt owner
  (`finding_common.SECRET_PATTERNS`) and the vault-sync fallback carry the
  same GitLab prefixes.
- Scenario J: the GitLab provider test proves an object-storage redirect on a
  package-file download is followed anonymously — the token stays at the API
  origin.
- Scenario M: `doctor` warns when a GitLab remote is observed while the
  legacy `forge-github` default is only projected, naming the explicit
  `feature switch forge-gitlab` transition.
- Scenario P: one end-to-end test asserts `update check`, `update`, `status`
  and `doctor` all surface the same `UPDATE_SOURCE_CONFLICT` for the same
  environment — one resolver, one refusal.
- Documentation parity (plan section 75): `tests/purity/test_docs_parity.py`
  enforces the EN/FR README heading hierarchy, the operational surface
  (commands, env vars, config files) and the critical security statements —
  structurally, never by raw line counts. CI runs it with the purity suite.
- Qualification report and CHANGELOG updated: scenarios J/M/P/Q and parity are
  GREEN; only the GitLab.com LIVE qualification and the release choreography
  remain, both declared.

Verified: 314 lifecycle tests OK, 245 other suites OK (18 scanner, 110
release/claims/purity), gates green.

Refs #166

* docs(qualification): GitLab.com qualified live (V3 chain, exact-runtime gate) (#166) (#175)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant