Found while answering Discussion #15758's Axis 4 (trigger policy) discriminating question as Lane 2's owner — "is Option E's exact-SHA floor actually performable end-to-end?"
#15774 (merged 3b36c2a323) made the deployed revision expressible declaratively in Compose: all three Neo build blocks now forward NEO_REF and NEO_REVISION. learn/agentos/cloud-deployment/PipelineWiring.md documents the resolve-then-pin invocation.
The reference pipeline that PipelineWiring.md tells downstream teams to call does not pass either argument. So the guide's manual path pins the revision and the scripted path does not — two documented paths with divergent behaviour, which is the "two realities" failure class reproduced inside the deployment tooling.
This is the same shape as #15774 itself, one layer out. There, the Dockerfile accepted NEO_REF and Compose never forwarded it. Here, Compose forwards it and the reference invoker never sets it. Fixing a channel does not wire the caller.
The Problem
Verified at origin/dev@a157efadfd:
ai/examples/cloud-deployment/deploy-pipeline.sh:52 — the build/deploy step is compose up -d --build --wait.
grep -nE "NEO_REF|NEO_REVISION" over that script → no matches. It handles NEO_DEPLOY_COMPOSE_FILE (:24), NEO_DEPLOY_PROJECT_NAME (:29-30) and NEO_DEPLOY_PROFILES (:33,:38) only.
The script sources no env file and exports nothing else — grep -nE "export|source|--env-file|\.env" returns only the NEO_DEPLOY_PROJECT_NAME export.
ai/deploy/docker-compose.yml declares no env_file for the build args, and there is no .env in ai/deploy/ for Compose to auto-read.
So the args resolve to their defaults on every scripted run: NEO_REF=dev, NEO_REVISION= empty.
Consequences, each independently bad:
Every deploy through the reference pipeline is unpinned. It builds mutable dev, so it cannot be a reproducible rollout — and per #15774's own reasoning, Docker does not automatically invalidate a RUN layer when remote content changes, so re-running --build does not even prove the branch was re-fetched.
Every such image asserts no revision.org.opencontainers.image.revision renders empty — correct behaviour for "not asserted", but it means the scripted path can never produce the receipt the deployment needs.
The two documented paths disagree. A reader following the guide's export-then-build snippet gets provenance; a reader calling the script the same guide recommends does not. Nothing warns them.
Discussion #15758's Option E floor is not performable through the documented pipeline. The floor is manual exact-SHA + stronger receipts; the reference automation can express neither half.
The Architectural Reality
ai/examples/cloud-deployment/deploy-pipeline.sh — CI-system-neutral by design, called by a CI job on the deployment host. It correctly pins --project-name for volume identity and refuses down -v. Revision identity is the one dimension it does not pin.
ai/deploy/docker-compose.yml — build blocks forward NEO_REF: ${NEO_REF:-dev} and NEO_REVISION: ${NEO_REVISION:-} (from #15774). The :- defaults are deliberate no-op protection; they are also what makes the omission silent.
learn/agentos/cloud-deployment/PipelineWiring.md — the Deployed-revision provenance section documents git ls-remote → export both → build. That is the correct recipe; it is simply not what the script does.
#15774's three provenance surfaces (org.neomjs.image.requested-ref, org.opencontainers.image.revision, /app/.neo-revision) all behave correctly. This is a wiring gap, not a design defect — the file-based surface still reports truthfully because it is resolved at build time regardless.
The Fix
Resolve-once in the script, then pass both, mirroring the guide's documented recipe:
ai/examples/cloud-deployment/deploy-pipeline.sh — accept a revision selector (NEO_REF, defaulting to today's behaviour so no existing deployment changes), resolve it to a full SHA via git ls-remote when it is not already 40 hex chars, export NEO_REVISION as the resolved value, and fail closed with a clear message if resolution fails. Echo the resolved revision before building so the CI log itself carries the receipt.
learn/agentos/cloud-deployment/PipelineWiring.md — state that the reference script pins by default and how to override, so the two paths stop disagreeing.
Acceptance Criteria
Author-canonical fold (@neo-gpt, D#15758 discussioncomment-17763297, 2026-07-24). Three shape changes are now binding, and one of them closes a false-provenance path my original ACs left open.
deploy-pipeline.sh resolves the caller's selector to a full 40-character SHA and exports bothNEO_REF=<resolved-sha>andNEO_REVISION=<resolved-sha> before Compose. Both, not just NEO_REVISION: the label alone does not change the source-git fetch/cache input, so setting only NEO_REVISION would stamp a resolved revision onto an image whose source stage fetched a mutable channel — a label asserting a fact the artifact does not hold, which is precisely the defect @neo-gpt-emmy's #15774 cycle-1 review caught. My original wording permitted it.
No unpinned default. The AC branch permitting dev to remain unpinned is removed — a documented-and-deliberate unpinned build is still an unpinned build, and the reference pipeline is exactly where that default propagates. Every run pins.
Resolution failure is fail-closed before Docker is invoked — zero matches and ambiguous/multiple matches both abort, with a message naming the selector. Never a silent dev fallback.
L2 harness proving the failure path (fake-git + fake-docker on PATH): a selector resolving to zero or many refs, and an abbreviated selector, each abort with fake-dockernever invoked. This is testable today with no Docker daemon, so the fail-closed contract is not deferred.
The resolved revision is echoed to stdout before the build, so the CI log is itself a provenance receipt.
The original higher-level selector (e.g. dev) is preserved in the pipeline log / request journal. After resolution org.neomjs.image.requested-ref necessarily reports the resolved build-input SHA, so the caller's intent is otherwise lost from the artifact. No new image surface is added for it here.
PipelineWiring.md no longer documents two paths with divergent pinning behaviour.
npm run ai:lint-guides and check-agentos-theme green.
[deferred verification — expiry 2026-08-24] — a real run produces an image whose org.opencontainers.image.revision matches the SHA the script echoed. Deferred because no agent sandbox has a reachable Docker daemon (colima stopped, verified). Scope shrunk by the L2 harness above: only the label-materialization half is deferred now, not the resolution or fail-closed logic. Tracked alongside #15787's receipts, never closed against.
Out of Scope
The three #15774 provenance surfaces themselves — merged, approved, field-confirmed.
Choosing a rollout authority, trigger automation, or controller — Discussion #15758 Axes 1–6 remain open; this ticket only makes the existing floor performable.
Cohort manifests and plane identity — gated on Discussion #15595's election.
pre-push-hook.sh and minimal-external-workspace/ in the same directory — untouched.
Avoided Traps
Hardcoding a branch in the script. A selector with a documented default keeps it CI-neutral; hardcoding would trade one wrong default for another.
Defaulting NEO_REVISION to the selector string. That would put a channel name in org.opencontainers.image.revision — the exact defect #15774's cycle-1 review caught. Resolution must produce a SHA or fail.
Silently falling back to dev on resolution failure. Fail-closed is the only honest behaviour: a deploy that cannot prove its revision should not proceed quietly. This mirrors the abbreviated-SHA git fetch failure documented in #15782.
Treating this as documentation-only. The guide already documents the right recipe. The defect is that the executable path disagrees with it, and only changing the executable path fixes that.
Live latest-open sweep: checked latest 12 open issues plus a targeted deploy-pipeline.sh reference script NEO_REF all-state search at 2026-07-24T09:40:47Z; no equivalent found. A2A in-flight sweep: no overlapping [lane-claim] — #15791 is the data-root reconcile slice, #15789/#15790 are unit-suite items, all disjoint. Structure-map gate: no new .mjs and no relocation; both touched files are existing members of ai/examples/cloud-deployment/ and learn/agentos/cloud-deployment/.
Context
Found while answering Discussion #15758's Axis 4 (trigger policy) discriminating question as Lane 2's owner — "is Option E's exact-SHA floor actually performable end-to-end?"
#15774(merged3b36c2a323) made the deployed revision expressible declaratively in Compose: all three Neo build blocks now forwardNEO_REFandNEO_REVISION.learn/agentos/cloud-deployment/PipelineWiring.mddocuments the resolve-then-pin invocation.The reference pipeline that PipelineWiring.md tells downstream teams to call does not pass either argument. So the guide's manual path pins the revision and the scripted path does not — two documented paths with divergent behaviour, which is the "two realities" failure class reproduced inside the deployment tooling.
This is the same shape as
#15774itself, one layer out. There, the Dockerfile acceptedNEO_REFand Compose never forwarded it. Here, Compose forwards it and the reference invoker never sets it. Fixing a channel does not wire the caller.The Problem
Verified at
origin/dev@a157efadfd:ai/examples/cloud-deployment/deploy-pipeline.sh:52— the build/deploy step iscompose up -d --build --wait.grep -nE "NEO_REF|NEO_REVISION"over that script → no matches. It handlesNEO_DEPLOY_COMPOSE_FILE(:24),NEO_DEPLOY_PROJECT_NAME(:29-30) andNEO_DEPLOY_PROFILES(:33,:38) only.grep -nE "export|source|--env-file|\.env"returns only theNEO_DEPLOY_PROJECT_NAMEexport.ai/deploy/docker-compose.ymldeclares noenv_filefor the build args, and there is no.envinai/deploy/for Compose to auto-read.So the args resolve to their defaults on every scripted run:
NEO_REF=dev,NEO_REVISION=empty.Consequences, each independently bad:
dev, so it cannot be a reproducible rollout — and per#15774's own reasoning, Docker does not automatically invalidate aRUNlayer when remote content changes, so re-running--builddoes not even prove the branch was re-fetched.org.opencontainers.image.revisionrenders empty — correct behaviour for "not asserted", but it means the scripted path can never produce the receipt the deployment needs.export-then-build snippet gets provenance; a reader calling the script the same guide recommends does not. Nothing warns them.The Architectural Reality
ai/examples/cloud-deployment/deploy-pipeline.sh— CI-system-neutral by design, called by a CI job on the deployment host. It correctly pins--project-namefor volume identity and refusesdown -v. Revision identity is the one dimension it does not pin.ai/deploy/docker-compose.yml— build blocks forwardNEO_REF: ${NEO_REF:-dev}andNEO_REVISION: ${NEO_REVISION:-}(from#15774). The:-defaults are deliberate no-op protection; they are also what makes the omission silent.learn/agentos/cloud-deployment/PipelineWiring.md— theDeployed-revision provenancesection documentsgit ls-remote→ export both → build. That is the correct recipe; it is simply not what the script does.#15774's three provenance surfaces (org.neomjs.image.requested-ref,org.opencontainers.image.revision,/app/.neo-revision) all behave correctly. This is a wiring gap, not a design defect — the file-based surface still reports truthfully because it is resolved at build time regardless.The Fix
Resolve-once in the script, then pass both, mirroring the guide's documented recipe:
ai/examples/cloud-deployment/deploy-pipeline.sh— accept a revision selector (NEO_REF, defaulting to today's behaviour so no existing deployment changes), resolve it to a full SHA viagit ls-remotewhen it is not already 40 hex chars, exportNEO_REVISIONas the resolved value, and fail closed with a clear message if resolution fails. Echo the resolved revision before building so the CI log itself carries the receipt.learn/agentos/cloud-deployment/PipelineWiring.md— state that the reference script pins by default and how to override, so the two paths stop disagreeing.Acceptance Criteria
deploy-pipeline.shresolves the caller's selector to a full 40-character SHA and exports bothNEO_REF=<resolved-sha>andNEO_REVISION=<resolved-sha>before Compose. Both, not justNEO_REVISION: the label alone does not change thesource-gitfetch/cache input, so setting onlyNEO_REVISIONwould stamp a resolved revision onto an image whose source stage fetched a mutable channel — a label asserting a fact the artifact does not hold, which is precisely the defect @neo-gpt-emmy's #15774 cycle-1 review caught. My original wording permitted it.devto remain unpinned is removed — a documented-and-deliberate unpinned build is still an unpinned build, and the reference pipeline is exactly where that default propagates. Every run pins.devfallback.fake-git+fake-dockeronPATH): a selector resolving to zero or many refs, and an abbreviated selector, each abort withfake-dockernever invoked. This is testable today with no Docker daemon, so the fail-closed contract is not deferred.dev) is preserved in the pipeline log / request journal. After resolutionorg.neomjs.image.requested-refnecessarily reports the resolved build-input SHA, so the caller's intent is otherwise lost from the artifact. No new image surface is added for it here.PipelineWiring.mdno longer documents two paths with divergent pinning behaviour.npm run ai:lint-guidesandcheck-agentos-themegreen.[deferred verification — expiry 2026-08-24]— a real run produces an image whoseorg.opencontainers.image.revisionmatches the SHA the script echoed. Deferred because no agent sandbox has a reachable Docker daemon (colima stopped, verified). Scope shrunk by the L2 harness above: only the label-materialization half is deferred now, not the resolution or fail-closed logic. Tracked alongside#15787's receipts, never closed against.Out of Scope
#15774provenance surfaces themselves — merged, approved, field-confirmed.pre-push-hook.shandminimal-external-workspace/in the same directory — untouched.Avoided Traps
NEO_REVISIONto the selector string. That would put a channel name inorg.opencontainers.image.revision— the exact defect#15774's cycle-1 review caught. Resolution must produce a SHA or fail.devon resolution failure. Fail-closed is the only honest behaviour: a deploy that cannot prove its revision should not proceed quietly. This mirrors the abbreviated-SHAgit fetchfailure documented in#15782.Related
Related: #15774 Related: #15758 Related: #15782 Related: #15787 Related: #11733 Related: Discussion #15758
Live latest-open sweep: checked latest 12 open issues plus a targeted
deploy-pipeline.sh reference script NEO_REFall-state search at 2026-07-24T09:40:47Z; no equivalent found. A2A in-flight sweep: no overlapping[lane-claim]—#15791is the data-root reconcile slice,#15789/#15790are unit-suite items, all disjoint. Structure-map gate: no new.mjsand no relocation; both touched files are existing members ofai/examples/cloud-deployment/andlearn/agentos/cloud-deployment/.Origin Session ID: 92799c10-cb3b-4c01-a2b0-fd8552c3c02e
Retrieval Hint:
query_raw_memories("reference deploy pipeline unpinned NEO_REF not passed two documented paths").