RFC-0002: Declared-stack contracts and deferred repair design#

Status: accepted; reconciliation deferred Author(s): BCE maintainer, with AI-assisted drafting

Decision (2026-09-27): accepted because the five selected-source rule types are additive, preserve existing blueprint semantics, and fail closed when evidence is insufficient; the repair and reconciliation design remains deferred.

This document follows RFC-0001. On 2026-09-20 the human maintainer explicitly accepted the enforcement contract and directed local implementation: “Accept the enforcement contract; implement locally.” The accepted source proposal was commit a97d32c65897b85a11d5d9a3424c7b15ef41d768; the implementation foundation is fc76183f5cdfa4f51ebc99a780af1808a8ca0285.

Acceptance covers the five declared-stack rules, explicit source selection, refusal on insufficient evidence, ordinary-gate integration and versioned offline replay. Repair, reconciliation and cross-repository work are deferred. The reconciliation sections below remain proposals and create no implemented command or mutation authority.

The public scope discussion and maintainer decision are recorded in the RFC pull request. Acceptance authorizes a follow-on enforcement implementation PR and publishes this RFC through the normal documentation workflow; it is not an engine or npm release. The local development engine uses the proposed 0.5.0 identity so real minimum-engine checks apply; this is not evidence of a published 0.5.0 package. Public distribution, pinning and self-adoption remain subject to the required release sequence.

Summary#

Make the ordinary conformance gate enforce selected declared package, image and Node contracts alongside code and replay its facts offline. The enforcement and replay contract is accepted; repair and reconciliation remain deferred.

Motivation#

One ordinary gate checks code plus selected declared npm/pnpm packages, image references and Node declarations; repository repairs turn red to green with unchanged policy. Its complete fact artifact can reproduce the evaluation offline. Missing or insufficient evidence refuses. A later, separately accepted RFC may define reconciliation; this RFC does not authorize a repair command or repair artifact.

Two equality rules alone are powerful but do not meet the adoption journey's explicit forbidden-transitive-package and universal-image-pinning cases. Recommend five small rule types, sharing one provider and evaluator. Runtime allowlists use exact versions; defer semantic ranges, importer-specific selectors, installed-state claims, vulnerability checks, registry resolution and automatic application. There is no new semver-range dependency.

Design#

Authored source block#

Proposed additive optional top-level stack object, strict recursively. Example JSON fragment to merge into an otherwise valid EngineeringBlueprint:

{
  "minEngineVersion": "0.5.0",
  "stack": {
    "source": "npm-lockfile-v3",
    "lockfile": "package-lock.json",
    "packageManifest": "package.json",
    "imageFiles": ["Dockerfile", "compose.yaml"],
    "runtimeFiles": [".nvmrc"]
  }
}

source enum is npm-lockfile-v3 | pnpm-lockfile-v9. The initial feature requires an npm/pnpm repository-root package manifest and lockfile, including for image/runtime-only rules; arbitrary non-Node repositories are out of scope. Exactly one lockfile; v1 permits only repository-root package-lock.json / npm-shrinkwrap.json for npm, pnpm-lock.yaml for pnpm, and package.json for packageManifest. This avoids pretending current provider supports arbitrary workspace roots. imageFiles contains unique sorted exact relative paths, no globs; extension/parser compatibility validated. runtimeFiles contains a unique sorted subset of .nvmrc, .node-version, package.json; package.json means engines.node. Empty imageFiles is legal only without image constraints. Empty runtimeFiles is legal only without runtime constraints. Arrays explicitly required, including []; absence does not activate discovery.

All configured files must exist, be regular files, remain inside real repository root and have no symlink components. Invalid/missing inputs refuse, even when another candidate exists. Multiple package-manager root locks or inconsistent packageManager declarations refuse; do not silently use snapshot's existing precedence. Do not change snapshot's established behavior. Extend the existing extractor with a strict selection mode, reusing parsers and canonical manifest functions; avoid a parallel parser. Record precisely consumed files and hashes. For runtime selection, refuse conflicting relevant declarations rather than treating .nvmrc precedence as proof of consistency. Other present runtime declarations participate in conflict detection and are recorded; source omission cannot hide contradictory facts.

Image policy quantifies over selected files, never every repository image. The current automatic discovery is bounded to depth 3 and compose scanning is line-based: preserve that limitation explicitly. Selected image files need supported, fully interpretable relevant image declarations; unresolved ARG/variables, ambiguous compose syntax or stage classification refuse relevant image grading. A compose build-only service is not a resolved image; never invent one. With current parser limits, conservative refusal may make compose unsuitable for the initial consumer fixture; a supported Dockerfile is enough to prove the vertical journey. Do not suppress known coverage limits to obtain green.

Changed-file selection is conservative: union existing code selector with every configured stack path, blueprint/config changes, alternative root locks, all runtime candidate paths and relevant package-manager config. Deletions/renames retain old paths from change input. New unselected image files do not expand this explicit contract; report selected scope prominently. Unknown/truncated change lists select the blueprint. All gate modes share selection.

Rule JSON and semantics#

Existing id/severity fields remain. Proposed new fields are legal only on their rule arms; reject extraneous combinations. Every arm has evidence class declaredStack; none falls through to staticAst.

[
  {"id":"stack-package-pin","type":"pinnedVersion","severity":"high","stackTarget":{"kind":"npm","name":"is-number"},"expectedVersion":"7.0.0"},
  {"id":"stack-image-pin","type":"pinnedVersion","severity":"high","stackTarget":{"kind":"oci-image","name":"node"},"expectedVersion":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
  {"id":"stack-runtime-pin","type":"pinnedVersion","severity":"high","stackTarget":{"kind":"node-runtime","name":"node"},"expectedVersion":"22.22.2"},
  {"id":"stack-closure","type":"stackClosureMatch","severity":"high","expectedStackDigest":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"},
  {"id":"no-legacy-package","type":"forbiddenStackPackage","severity":"high","packageName":"legacy-package"},
  {"id":"images-immutable","type":"requirePinnedImages","severity":"high"},
  {"id":"supported-node","type":"allowedNodeVersions","severity":"high","versions":["22.22.2","24.8.0"]}
]

Digests above are illustrative, not trusted release facts. Names compare exactly to existing extractor names; no registry alias inference. npm pin selects every modeled non-root npm identity of that name, including transitive, dev, optional, peer and platform-conditional entries. At least one match required; a known absent selected package is a violation. Every occurrence must have exact expected version. npm expectedVersion is a literal exact semver x.y.z with optional standard prerelease/build suffix, no prefix v, range, alias, URL or tag. Comparison is literal string equality (build metadata remains distinct); invalid authored values refuse validation. Any opaque npm entry makes this all-occurrence pin ungradeable, even alongside conforming modeled matches. An exact npm version is not a tarball-integrity rule; label this distinction. An unresolved selected image declaration that might match a named pin refuses that pin as well as universal image rules; known mismatches remain diagnostic violations. OCI pin compares digest, not mutable tag, for every selected declaration whose parsed image name matches; require at least one match. Runtime pin requires literal exact normalized x.y.z, not 22, lts/*, ranges or an executing process version. These rules never use machine Node version.

stackClosureMatch compares canonical stackDigest, including hashed opaque entries; it does not assert topology/importer identity, source-byte equality, or installed closure. Opaque entries are permitted for this rule because their raw identity hashes are part of the comparison, but unread/invalid source material refuses. Coverage warnings remain visible. Source-only whitespace changes may alter manifestDigest while closure rule stays green.

forbiddenStackPackage requires zero matching non-root npm names. Positive modeled matches establish violations; inability to prove absence due to relevant opaque entries refuses. Initial implementation may conservatively refuse every npm opaque entry for this rule; do not infer alias target safety. requirePinnedImages requires at least one selected image and every selected image to have a syntactically valid sha256 digest. A complete scan with zero external images violates the rule's explicit existence requirement; an incomplete scan refuses. allowedNodeVersions is a nonempty sorted unique exact-version set; resolved declaration must equal an entry. Ranges/aliases are ungradeable, not ordinary mismatches. Both runtime rules may coexist; validation can reject contradictory authored exact policies rather than silently succeeding one arm.

Gradeability and outputs#

Input failure / insufficient relevant evidence => refusal2; complete known mismatch => violation1 in enforced mode; all constraints gradeable and conforming => pass0. Global invocation exit2 dominates any simultaneously proven violation. Retain proven violations alongside refusals for diagnosis, but never label whole evaluation conformant. Baseline cannot consume refusals and advisory cannot soften them. Ordinary graded violations retain existing baseline/advisory semantics, explained below.

Map source/extraction limitations to facts explicitly: package unread/unsupported lockfile invalidates package rules and closure; relevant unresolved image invalidates image rules and closure when identity cannot be represented; runtime conflict invalidates runtime and closure. An irrelevant image warning need not invalidate a package-only policy, but must be displayed. Unknown coverage codes default to refusing affected stack evaluation until classified. Use a typed internal gradeability result; do not rely on permissive substring guessing of human warning text.

Use existing Violation shape: component npm:legacy-package, oci-image:node, or node-runtime:node; evidenceType declaredStack; observed and expected strings; truthful evidenceRef e.g. package-lock.json#/packages/node_modules~1legacy-package (JSON Pointer) or Dockerfile#L1. Never fabricate line numbers. Deduplicate identical identity violations deterministically; retain occurrence/source anchors in stack details. Stable violation identity should follow existing baseline machinery, reviewed against source identity changes.

Concrete report and evidence envelope#

Preserve schemaVersion1 report/bundle bytes and scoring exactly for non-stack policies. Recommend new schemaVersion2 report and bundle ONLY when a declaredStack constraint is present. This is intentional protocol versioning: current evidence verifier hard-checks version1 and replays only graph facts, so a silent v1 extension risks false compatibility. Do not alter a legacy report merely because a snapshot happened elsewhere.

V2 report reuses existing blueprintRef, ctRepoRevision, repo, score, violations, evidenceRef, summary and optional mode. Its verdict adds indeterminate. Replace legacy mandatory AST coverage with explicit providers; stack-only report has no invented graph or AST success. The complete illustrative bundle below defines the proposed envelope. Report coverage contains a provider array, with no mandatory AST provider. stack is the four-field digest/claim object shown there, or null only if acquisition failed before a complete artifact existed. Refusals contain {constraintId,evidenceClass,code,reason,sources:string[]}, sorted by constraintId/code/sources. Score is null exactly when verdict is indeterminate; otherwise the existing numeric severity weighting applies. Proven violations survive alongside refusals. Invalid authored blueprints remain invocation errors, not invented reports.

A complete artifact may produce a refusing report and still be bundled for replay. If acquisition fails before a complete manifest/provider-facts pair exists, emit an indeterminate report with stack null and no portable bundle; never fill a manifest with invented empty facts. Mixed reports include the existing staticAst coverage under its own provider entry; existing behaviorObservation semantics remain. Portfolio/Markdown/JUnit/SARIF must handle null explicitly, never coerce it to 0 or 100.

V2 bundles contain blueprint, stackManifest, stackFacts and report. Mixed bundles additionally contain graph and hashes.graph; stack-only bundles omit both and use extractionProfile null. V2 toolchain replaces the old singular extractor with providers: staticAst entries reuse its exact extractor fields; declaredStack uses {evidenceClass:"declaredStack",provider:"selected-stack-v1",version:<engine-version>}. Existing engine, dependencyLock and runtime fields remain. This is a v2-only change; no v1 identity changes. Every artifact receives its own canonical SHA256, including stackFacts. The bundle hash covers the entire canonical envelope with only hashes.bundle omitted. Report evidenceRef names the provider-facts digest, avoiding circular bundle/report identity. Report stack digest fields bind the manifest; stackFacts binds the same manifestDigest and sourceConfigDigest.

StackManifest remains strict schema1 without new fields. Hash the full canonical manifest including manifestDigest, and independently recompute manifestDigest, stackDigest and stackId using existing functions. Exact provider facts, source inventory and replay checks follow below. All new objects are strict; no implicit extra fields.

Offline verifier parses blueprint, enforces engine minimum/capability, verifies every artifact hash and complete bundle hash, validates manifest invariants/source binding, re-evaluates using the SAME shared evidence dispatcher, and byte-compares reproduced report. Hash bundle body with bundle hash omitted, same canonical serializer. Missing/substituted manifest, mismatched sources/revision/config, unused injected graph or incompatible coverage must refuse. An attacker who changes both facts and report and rehashes the entire unsigned bundle can create a different internally consistent artifact; integrity cannot establish provenance. Do not promise rejection of every coherent forgery. Independent re-extraction from trusted source is an additional provenance check, not bundled replay.

Evidence records and review lineage include optional stackDigest AND manifestDigest only when derived from the evaluated artifact; omit both otherwise. Full manifest must travel in portable evidence; digest-only rows do not replay facts. Review binds candidate/source identities as today; stack data never supplies approval authority.

Compatibility and baseline decisions#

Old engine strict parsing rejects top-level stack; new constraints also require minEngineVersion0.5.0 (version provisional until release owner chooses). Regression must exercise old published engine and tolerant parsing: new policy cannot become green after unknown constraints are skipped. New engine + old blueprint yields byte-identical old report and v1 evidence; new + stack blueprint yields v2. No schema-only release that accepts inert constraints. Installed blueprint and baseline stay unchanged throughout implementation.

Use existing baseline treatment for established, gradeable violations: an explicitly reviewed committed baseline can mark them pre-existing. Do NOT silently special-case stack violations to bypass established baseline semantics. The acceptance journey uses unchanged empty baseline so actual package/image/runtime violations fail1. Refusal cannot be baselined, ever. Capturing a fresh baseline is a protected relaxation; reconcile cannot generate/install it. Changing pinnedVersion or closure digest is generally incomparable/unknown-potential-relaxation, not automatic tightening. Removing a constraint or sources relaxes; changing source identity is unknown. Adding a constraint tightens only with well-formed unchanged source scope. Narrowing exact Node allowlist tightens, widening relaxes; runtime parser/rule changes remain protected code review.

Deferred design appendix: proposal-only reconciliation (not accepted)#

Everything in this section and the later reconciliation-vector section is retained as design context only. It is outside the accepted enforcement and replay scope and does not authorize implementation, publication, or mutation.

Do not depend on nonexistent stack resolve. Initial proposed invocation is stack reconcile --base <bundle-v2> --head <bundle-v2> --target <bundle-v2> --blueprint <blueprint> --out <quarantine-json>. Each input is a complete v2 evidence bundle carrying manifest and typed stackFacts, not a bare manifest. Verify each bundle with the unchanged supplied blueprint; head may have a gradeable failing report, while base and target must be gradeable and conformant. All manifests must pass complete identity and source-binding validation; the blueprint is the unchanged reviewed policy. Verify the current repository's source hashes/revision against head. Base explains the observed drift and must satisfy the same policy; it is never a file-restoration instruction. Target is an explicitly supplied desired declaration state, not a registry resolution result or authority to mutate anything.

Require target to be fully gradeable and conformant under unchanged policy. Compute head→target through the existing stack-diff implementation; only forward or identical permits a proposal. Unknown/backward refuses even if the desired policy passes. A useful target may therefore not exist for an exact pin or closure rule after an unwanted upgrade: report that honestly and point to separately reviewed forward dependency repair or policy review, never restore older source files or weaken the classifier. Base→head classification is explanatory only; it cannot authorize a backward head→target repair. For identical closure with changed declarations, bind and compare full manifests and source hashes; identical stackDigest is not evidence that files or topology are identical.

Proposed envelope: {schemaVersion:"1",kind:"StackReconcileProposal",blueprintDigest,baseManifestDigest,headManifestDigest,targetManifestDigest,baseBundleDigest,headBundleDigest,targetBundleDigest,sourceConfigDigest,direction,obligations,limitations,proposalDigest}. Here direction is exactly forward | identical for head→target; canonical proposalDigest excludes itself. No timestamps or machine-dependent absolute paths. Each obligation has this shape:

{
  "kind":"satisfy-forward-stack-target",
  "constraintIds":["images-immutable"],
  "sources":[{"path":"Dockerfile","expectedHeadSha256":"<hex>","targetSha256":"<hex>"}],
  "targetManifestDigest":"<hex>",
  "facts":[{"kind":"oci-image","name":"node","expectedDigest":"sha256:<64 hex>"}],
  "patchStatus":"not-produced"
}

Fact arms are explicit discriminated requirements: npm {kind:"npm",name,expectedVersions:string[]}, OCI as above, Node {kind:"node-runtime",name:"node",expectedVersion}, and closure {kind:"stack-closure",expectedStackDigest}. Facts describe the supplied target, not a package-manager command or a claim that an arbitrary file edit yields that target. Require unambiguous fact→source mapping; otherwise refuse to manufacture an actionable obligation. A digest in the target does not prove image availability or origin authenticity.

The target's sources are declared hash commitments until their desired bytes are supplied. A valid manifest can support an internally consistent target obligation but cannot prove that an exact patch exists. Therefore initial output always says patchStatus:"not-produced"; it does not include invented bytes, a diff, shell commands, or an apply operation. Any future exact-patch mode must accept contained desired-source files, check their hashes, re-extract the target with the same strict source configuration and compare the complete produced manifest before claiming a patch. That extension requires an accepted design; do not infer it from this proposal envelope. Source additions/deletions or unsupported mappings refuse initial obligation generation rather than inventing missing hashes.

No writes except explicit quarantine output. Reject output/input aliases, hardlinks and symlink paths; no npm/pnpm/network/process mutation, baseline change, policy rewrite, ratification or automatic application. If no provable conformant forward target exists, refuse with precise reasons and review guidance. Fix-forward-only holds independently of whether the old base was once conformant.

Implementation work orders after acceptance#

  1. Schema/capability + selected-source provider: generated schemas, strict cross-field validation, minimum-engine/tolerant-parse vectors, source and coverage typing; new types refuse until full integration. No partial shipped acceptance.
  2. Shared evaluation + gate selection: five rule arms, pure evaluation, mixed/stack-only facts, report2 and refusal aggregation, baseline/mode/portfolio/renderers; CLI/MCP/teeth all call same preparation seam.
  3. Replay + lineage + teeth: bundle2, record binding, verifier1 compatibility, synthetic evaluator mutations and real lockfile/Dockerfile/runtime source mutations clearly distinguished.
  4. Packed consumer/CI: genuine install, initial three independent violations, repairs with fixed policy, source deletion/opaque/variable/runtime conflict refusal, lockfile-only gate selection, offline replay in separate temp consumer, intentional manifest substitution/tamper, old-engine fail-closed. Generated CI check must exercise ordinary gate. Release/pin/self-adoption are subsequent owner-controlled acts.

Accepted decision scope#

Accepted: explicit selected-file image scope (not whole-repository coverage); five rule types with exact-version runtime sets rather than ranges; all-occurrence package pin; package exact version does not assert integrity; closure digest does not assert topology; strict source ambiguity refusal; existing baseline/advisory semantics; and schema2 stack report/bundle with null indeterminate score. Public 0.5.0 distribution, pinning, and self-adoption follow publication and predecessor-gated activation of the 0.4.0 foundation. Proposal-only forward-target obligations remain deferred.

Portable provider facts and source-binding algorithm#

stackFacts is a proposed strict artifact with exactly: schemaVersion "1", kind "DeclaredStackFacts", provider "selected-stack-v1", ctRepoRevision, manifestDigest, sourceConfigDigest, inventory, domains and limitations. It is not an addition to StackManifest v1. Domains is an object with exactly packages, images, runtime, closure; each value is complete | incomplete | not-selected. A domain is complete only after its selected source scan completed. Runtime and images are not-selected exactly when their authored arrays are empty. Packages and closure cannot be not-selected because a lockfile and package manifest are mandatory. A closure rule requires image/runtime arrays to describe its intended scope explicitly; empty arrays exclude those domains, not discover them implicitly. The resulting digest is a selected closure, not a claim about all repository files.

Inventory is a unique path-sorted array. Each row is exactly {path,role,present, sha256,parser}. Role is selected | conflict-check. Present files have SHA256; absent files have null SHA256. Parser is the corresponding manifest parser, or package-manager-config for .npmrc and pnpm-workspace.yaml. These two config files are detection-only: a present nonempty config whose effect cannot be fully characterized by the existing provider refuses package/closure grading. The initial strict mode conservatively refuses any nonempty one; it never silently applies configuration or executes hooks. Empty means zero non-whitespace bytes.

The finite inventory is exactly the union of authored paths and these root paths: package-lock.json, npm-shrinkwrap.json, pnpm-lock.yaml, package.json, .nvmrc, .node-version, .npmrc, pnpm-workspace.yaml. No glob or recursive ambient scan. Authored entries have role selected, even if also a conflict-check candidate. There are no other allowed additional files. Known root locks use their corresponding parser; .nvmrc uses nvmrc and .node-version node-version. Selected image paths must be Dockerfile/Dockerfile.* basenames or compose.yaml, compose.yml, docker-compose.yaml, docker-compose.yml; parser follows that class. Paths use canonical relative POSIX segments, no empty/dot/parent segments, backslash, absolute paths or NUL. This proposal excludes arbitrary alternate image filenames.

Runtime conflict checks read all present runtime candidates including package.json engines.node even when not selected. If any relevant declaration is non-exact or exact declarations disagree, runtime and closure become incomplete whenever runtime is selected. Without selected runtime, those declarations are recorded as detected but contribute no runtime node/value to the manifest and do not grade runtime. PackageManager must match the selected lock family; missing declaration is allowed, unsupported or inconsistent declaration refuses. More than one present root lock refuses packages and closure; an alternative is never silently preferred. Symlink, nonregular or escaping candidate inputs refuse acquisition, including conflict checks.

For a completed scan, manifest.sources is exactly the selected present file set: lockfile, packageManifest, imageFiles and runtimeFiles (deduplicated). It excludes conflict-check-only files. Those hashes remain bound in stackFacts. Unreadable is never absent: permission/read errors abort acquisition. A selected package.json has one row/hash even when it supplies package metadata and runtime; uses follow from the authored configuration, not duplicated rows. Manifest coverage.filesScanned equals that selected set's size; report's declaredStack filesScanned equals the same count, not the number of absence probes. Images may only have evidenceRef paths in selected imageFiles. Runtime resolvedFrom must refer to a selected runtime source; package nodes/rootDeclared/edges come from the selected lock family. Package-json supplies metadata and declaration consistency, never a second dependency resolver. Distinct exact runtime spellings normalize only a single leading v; no range evaluation is introduced. Snapshot's existing auto-discovery contract remains unchanged; strict selection is a new mode of the shared provider.

Limitations is a deterministic array of exactly {code,domains,sources,message, manifestWarningIndexes}. Codes are opaque-package-entry, unresolved-image, runtime-conflict, non-exact-runtime, ambiguous-package-manager, unsupported-package-manager-config, unsupported-source-syntax, unclassified-coverage. Domains is a sorted nonempty subset of the four domain names; sources contains sorted inventory paths. Warning indexes index the unchanged manifest.coverage.unsupported array. Each warning index occurs exactly once, and message is exactly that warning for entries carrying one index. Entries with no index are permitted for additional strict selection checks. Grouping multiple warnings in one entry is forbidden initially. Unknown codes, unmatched warnings, missing domains or duplicate indexes refuse all stack evaluation; they never grant completeness. Producer branches emit typed codes at the point the limitation is observed. An absent peer explicitly marked optional by peerDependenciesMeta is a known npm installation choice, not lost closure coverage: the automatic snapshot may retain its diagnostic note, while strict selected facts do not emit a limitation for that absence. Missing required peers, dependencies and optional dependencies remain coverage limitations. Human warning substring matching is prohibited.

The verifier checks code/domain rules independently: opaque-package-entry requires constraint-level package uncertainty but leaves the packages domain complete when every entry was enumerated and hashed; it does not invalidate closure when its full raw entry hash is in unmodeled; unresolved-image affects images and closure; runtime-conflict/non-exact-runtime affects runtime and closure if selected; ambiguous-package-manager and unsupported config affect packages and closure; unsupported syntax and unclassified coverage affect all selected domains. Packages complete means complete enumeration including explicit opaque entries, not knowledge of their names. Any opaque entry makes package pin/forbidden-name rules ungradeable, conservatively, even if a matching modeled entry proves a diagnostic violation. A closure rule may still compare opaque-entry identities. Unknown limitation text/code is never evidence of safety. Domains marked incomplete must agree with these rules; an unexplained incomplete value refuses, never upgrades itself to complete.

Offline verification performs these steps in order:

  1. Strict-parse blueprint and envelope; enforce the declared engine floor and actual implemented capability. Recompute every artifact and envelope hash.
  2. Recompute all three manifest identities. Check graph, manifest, facts and report revisions agree where present. Check facts' manifestDigest and canonical hash of the blueprint's exact stack object. Reject injected unused graph or source rows.
  3. Reconstruct the finite required inventory from policy. Validate its exact paths, roles, parsers, present/hash pairing, required selected presence, selected source equality with manifest.sources, and absence/presence conflict rules above.
  4. Validate typed limitations, warning-index coverage and derived gradeability. Domain completeness records enumeration; per-constraint gradeability additionally accounts for opaque identities, exact runtime requirements and unresolved selectors. Cross-check all manifest image anchors, runtime origin, opaque entries and source parsers against that inventory. Require opaque entries to have their typed limitation; an opaque-free manifest cannot claim that limitation. Apply package manager/runtime conflict facts conservatively. Report provider coverage must match this validated record, not a caller-supplied green assertion.
  5. Run the shared evidence dispatcher and report serializer, then compare the entire reproduced report canonically. V2 evidence requirements must include required declaredStack/onMissing:block when any stack constraint exists; mixed policies retain required evidence for their other constraint classes. Missing required evidence refuses before ordinary scoring.

These checks establish internal consistency only. Neither inventory absence, source hashes, typed limitations, nor extracted facts prove what was in a real repository. Re-extraction from independently trusted source bytes at the stated revision, with the same strict configuration, is necessary to validate all those observations. In particular, hashing a config cannot prove the provider obeyed it. Coherently altered unsigned facts plus fresh hashes may pass internal replay. This is not provenance, signature verification, image availability or package authenticity.

Complete illustrative stack-only bundle#

The following is one complete proposed structural example, with no omitted required fields or empty placeholder artifacts. Hashes/revision are illustrative values, not measured evidence; this JSON is shape-valid by the proposed contract but is intentionally not a hash-verification vector. It cannot be presented as a passing real bundle. The manifest models an empty dependency closure for a real root package; forbidden-name absence is gradeable because there are no opaque entries. Absent runtime and image selections do not invent AST extraction or runtime observation.

{
  "schemaVersion": "2",
  "kind": "BceEvidenceBundle",
  "claim": "self-contained-integrity-not-origin-authenticity",
  "engine": {
    "name": "bce-engine",
    "version": "0.5.0"
  },
  "environment": {
    "node": "22.22.2",
    "npm": "10.9.4",
    "platform": "linux",
    "arch": "x64"
  },
  "toolchain": {
    "engine": {
      "name": "bce-engine",
      "version": "0.5.0"
    },
    "dependencyLock": {
      "file": "npm-shrinkwrap.json",
      "sha256": "0000000000000000000000000000000000000000000000000000000000000000"
    },
    "runtime": {
      "node": "22.22.2",
      "npm": "10.9.4",
      "platform": "linux",
      "arch": "x64"
    },
    "providers": [
      {
        "evidenceClass": "declaredStack",
        "provider": "selected-stack-v1",
        "version": "0.5.0"
      }
    ]
  },
  "invocation": {
    "command": "bce run",
    "extractionProfile": null
  },
  "artifacts": {
    "blueprint": {
      "apiVersion": "blueprint-conformance/v1alpha1",
      "kind": "EngineeringBlueprint",
      "metadata": {
        "id": "no-legacy",
        "version": "0.1.0",
        "status": "draft"
      },
      "intentRefs": [
        "policy/no-legacy"
      ],
      "scope": {
        "repositories": [
          "example/app"
        ]
      },
      "architecture": {
        "components": [],
        "relationships": []
      },
      "constraints": [
        {
          "id": "no-legacy",
          "type": "forbiddenStackPackage",
          "severity": "high",
          "packageName": "legacy-package"
        }
      ],
      "evidenceRequirements": [
        {
          "type": "declaredStack",
          "required": true,
          "onMissing": "block"
        }
      ],
      "approvals": [],
      "minEngineVersion": "0.5.0",
      "stack": {
        "source": "npm-lockfile-v3",
        "lockfile": "package-lock.json",
        "packageManifest": "package.json",
        "imageFiles": [],
        "runtimeFiles": []
      }
    },
    "stackManifest": {
      "schemaVersion": "1",
      "kind": "StackManifest",
      "ctRepoRevision": "1111111111111111111111111111111111111111",
      "sources": [
        {
          "path": "package-lock.json",
          "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
          "parser": "npm-lockfile-v3"
        },
        {
          "path": "package.json",
          "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
          "parser": "package-json"
        }
      ],
      "nodes": [
        {
          "id": "npm:example-app@1.0.0",
          "kind": "npm",
          "name": "example-app",
          "version": "1.0.0",
          "integrity": null,
          "resolvedFrom": "lockfile",
          "dev": false,
          "optional": false,
          "peer": false,
          "platformConditional": null,
          "root": true,
          "devOptional": false,
          "installScript": false,
          "resolvedWhenUnpinned": null,
          "layout": [
            ""
          ]
        }
      ],
      "edges": [],
      "runtime": {
        "node": {
          "declared": null,
          "resolvedFrom": null,
          "pin": false
        }
      },
      "images": [],
      "rootDeclared": [],
      "unmodeled": [],
      "coverage": {
        "unsupported": [],
        "filesScanned": 2
      },
      "stackDigest": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
      "stackId": "stack:cccccccccccc",
      "manifestDigest": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
    },
    "stackFacts": {
      "schemaVersion": "1",
      "kind": "DeclaredStackFacts",
      "provider": "selected-stack-v1",
      "ctRepoRevision": "1111111111111111111111111111111111111111",
      "manifestDigest": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
      "sourceConfigDigest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
      "inventory": [
        {
          "path": ".node-version",
          "role": "conflict-check",
          "present": false,
          "sha256": null,
          "parser": "node-version"
        },
        {
          "path": ".npmrc",
          "role": "conflict-check",
          "present": false,
          "sha256": null,
          "parser": "package-manager-config"
        },
        {
          "path": ".nvmrc",
          "role": "conflict-check",
          "present": false,
          "sha256": null,
          "parser": "nvmrc"
        },
        {
          "path": "npm-shrinkwrap.json",
          "role": "conflict-check",
          "present": false,
          "sha256": null,
          "parser": "npm-lockfile-v3"
        },
        {
          "path": "package-lock.json",
          "role": "selected",
          "present": true,
          "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
          "parser": "npm-lockfile-v3"
        },
        {
          "path": "package.json",
          "role": "selected",
          "present": true,
          "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
          "parser": "package-json"
        },
        {
          "path": "pnpm-lock.yaml",
          "role": "conflict-check",
          "present": false,
          "sha256": null,
          "parser": "pnpm-lockfile-v9"
        },
        {
          "path": "pnpm-workspace.yaml",
          "role": "conflict-check",
          "present": false,
          "sha256": null,
          "parser": "package-manager-config"
        }
      ],
      "domains": {
        "packages": "complete",
        "images": "not-selected",
        "runtime": "not-selected",
        "closure": "complete"
      },
      "limitations": []
    },
    "report": {
      "schemaVersion": "2",
      "blueprintRef": "no-legacy@0.1.0",
      "ctRepoRevision": "1111111111111111111111111111111111111111",
      "repo": "example/app",
      "score": 100,
      "verdict": "pass",
      "violations": [],
      "evidenceRef": "declared-stack-facts.json@sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
      "summary": "1 constraint(s) evaluated; 0 violation(s); score 100",
      "coverage": {
        "providers": [
          {
            "evidenceClass": "declaredStack",
            "filesScanned": 2,
            "unsupported": []
          }
        ]
      },
      "stack": {
        "stackDigest": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
        "manifestDigest": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
        "sourceConfigDigest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
        "claim": "selected-declarations-not-installed-state"
      },
      "refusals": []
    }
  },
  "hashes": {
    "blueprint": "1111111111111111111111111111111111111111111111111111111111111111",
    "stackManifest": "2222222222222222222222222222222222222222222222222222222222222222",
    "stackFacts": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
    "report": "3333333333333333333333333333333333333333333333333333333333333333",
    "bundle": "4444444444444444444444444444444444444444444444444444444444444444"
  }
}

Acceptance and compatibility matrix#

These are required implementation vectors, not claims that this draft implements them.

CaseRequired result
New engine, legacy blueprint, same graph/invocationByte-identical v1 report/bundle, scoring, violation identity and evidence chain; no stack key added
Published old engine, new stack policyRefusal/nonzero, never green; strict and tolerant parsing both exercised; minimum-engine check cannot be bypassed by skipped constraints
Stack object with no stack constraintReject new authored configuration as inert; never change a legacy report merely because a snapshot exists
Stack constraint without stack source/required evidence/floorAuthoring refusal; no schema-only acceptance before executable implementation
Stack-only complete evidenceV2 report and bundle, no graph or AST coverage; exact manifest and facts included
Mixed staticAst and declaredStackBoth providers required and evaluated once through shared dispatcher; failure/missingness in either cannot disappear through selection
Proven violation plus any refusalKeep violations; verdict indeterminate, score null, exit2 even in advisory or with baseline
Gradeable violation with committed baselineExisting matching/fingerprint semantics; baseline changes separately reviewed; no automatic stack exemption
Gradeable violation in advisoryExisting advisory exit behavior, measured fail remains visible; no claim of enforced conformance
Portfolio member with null scorePortfolio exit2; aggregate numeric score null, retain other member results; never average away missingness
Markdown/JUnit/SARIF with refusalExplicit indeterminate/error with refusal code; no numeric fabricated score or passing test; SARIF invocation executionSuccessful false
Unselected image file appearsDoes not expand selected image scope; report continues naming the selected files
Lockfile-only deletion/rename/changeSelect applicable blueprint in every gate mode; deleted/old name retained; missing selected source refuses
Unknown/truncated changed-file listConservatively select blueprint
New exact pin or closure digestIncomparable/unknown-potential-relaxation unless proven equivalent; never label an arbitrary replacement tightening
Remove constraint/source or widen Node setProtected relaxation; no silent ratification
Narrow Node set with same source scopeTightening, subject to ordinary review and satisfiability validation
Add known constraint with unchanged well-formed sourcesAdditive tightening; changes to source identity remain unknown
Stack bundle tamper/substitution without coherent rehashIntegrity/replay refusal; coherent unsigned replacement still does not establish origin
Runtime machine version differsNo effect on declaration grading; recorded execution toolchain remains honest

Baseline violation fingerprints retain the existing SHA256 tuple of blueprintRef, constraintId and component, separated by NUL. Observed, expected and evidenceRef are explicitly excluded, so changed observed versions at the same component may still match an existing baseline; this inherited behavior is not a fresh approval. Occurrence line numbers do not create or erase baseline identity. Multiple identical identity violations are deduplicated; their truthful source anchors remain available in the manifest/provider evidence. Exact tuple tests must accompany implementation. Source identity changes still require policy review.

All public entry points must consume the same prepared evidence: run, gate (all modes), MCP wrappers, teeth, portfolio, replay and renderers. No stack implementation may hide behind a snapshot-only command while an ordinary gate skips its constraints. Synthetic evaluator teeth and separately materialized source mutations are different claims and must be reported separately. The packed consumer journey must start with independent package/image/runtime violations, then fix declarations with unchanged policy and empty baseline; refuse missing/opaque/variable/conflicting input; replay in a separate offline consumer. Six portability legs must run the built consumer proof.

Deferred reconciliation vectors and limits (not accepted)#

A genuinely possible positive case uses allowedNodeVersions:["22.22.2","24.8.0"] with .nvmrc as the sole selected runtime declaration, no conflicting engines/node- version declaration, unchanged modeled npm closure, no image changes and no opaque entries. Base declares22.22.2, head23.0.0, target24.8.0. Base and target satisfy the same allowlist; head is a complete known violation. Existing exact-runtime comparison orders head→target forward. No image resolution, historical restoration or invented patch is needed. The proposal asks for target24.8.0 using target source hashes; it still does not assert those bytes exist or run an installer.

VectorRequired result
Positive allowlist case above, fresh head, valid bundlesDeterministic forward obligation for .nvmrc, patchStatus not-produced
Same policy/head, target22.22.2 historical baseBackward refusal even though target conforms
Mutable image tag becomes digest with unordered identityRespect existing unknown classification; refuse, never assume pinning is forward
Unsupported/nonsemver runtime or changing opaque package identityUnknown or gradeability refusal; never synthesize ordering
Current .nvmrc hash differs from head inventoryStale-head refusal before any output
Target bytes not supplied, otherwise valid commitmentsObligation may be emitted, explicitly no patch/no realizability or provenance claim
Ambiguous fact-to-source mapping, missing required target source, source addition/deletionRefuse actionable proposal generation
Target conforms but head→target classification is added/removed/flags-changed/rewrittenRefuse in initial scope; only exactly forward or identical is eligible
Identical stackDigest but changed source bytes/topologyCompare complete manifests and bound sources; no claim of byte identity
Output aliases any input via path/symlink/hardlinkRefuse without overwriting inputs
Base/head/target facts lack inventory, gradeability or report bindingRefuse; manifest-only input cannot support this interface

Freshness compares the entire current finite inventory (including absences and conflict-check files), source bytes and revision against head. Neither a clean Git label nor the revision alone establishes freshness. Re-extract current head with the strict provider and compare complete manifest and facts. The proposal output is the only write and must be outside every selected source/input; inputs remain immutable. A target bundle is a desired-state commitment, not authority to change policy.

Existing stack-diff has an unresolved wording conflict: root-priority pairing can classify a semver root move forward while an unchanged retained nonsemver copy exists; general ambiguity wording suggests an unconditional refusal. This RFC preserves current implementation and does not resolve that contradiction by implication. Reconciliation's initial domain additionally refuses any retained nonsemver package identity involved in a root/nonroot pairing ambiguity, even when existing diff says forward. Owner decision is whether a later, separately reviewed clarification should codify root priority or impose broader ambiguity refusal. Neither outcome is approved here, and no diff behavior changes are included in this draft.

Widen-only check#

The five new rule arms and source object add expressiveness. Legacy blueprints retain all enforcement and serialized v1 behavior. New policies require an explicit engine floor and fail closed on older engines, including tolerant parsing. A released schema must not accept new constraints until every ordinary gate path enforces them. The new evidence protocol is versioned, not silently stuffed into v1. Baseline, review, policy comparison and authority checks are preserved. Implementation and normative specification changes must land together after acceptance, as RFC-0001 requires.

Alternatives considered#

Compatibility and owner decision record#

The human maintainer accepted enforcement on 2026-09-20 with the explicit instruction quoted above. Accepted: selected-file scope; five rule types; all-occurrence package pins; exact Node sets; source-conflict refusal; unchanged baseline/advisory treatment with refusal precedence; v2 reports/bundles and null indeterminate scores; companion typed facts and ordinary public entry-point integration.

Deferred: forward repair obligations, reconcile commands, importer-specific scope, root/nonroot ambiguity changes and cross-repository orchestration. No policy/baseline ratification or engine/package publication is implied. The RFC itself is published as documentation when this decision lands. The local development version is 0.5.0, matching the accepted provisional minimum; engine release publication remains a separate owner-governed act. Existing code-only policies retain their established minimums and v1 output behavior.

Read Markdown · Source: rfcs/RFC-0002-declared-stack-contracts.md