diff --git a/README.md b/README.md
index 3a4f26ad..5e311f17 100644
--- a/README.md
+++ b/README.md
@@ -696,17 +696,17 @@ link that broke. `LedgerxWallet` -> `openXwallet` -> `openWallet` is the same
shape, and `openWallet` need not exist anywhere for that to be the answer.
**`validate-pins.py` rechecks the referent too, not only the declaration.**
-Every `neutral_product_pins:` entry's `contracts/-pin.yaml`
-has its `commit`, `revision_kind` and `sorted-ls-tree-r-v1` digest recomputed
-exactly the way `scaffold-project.py --pin` computed them: OFFLINE from a
-local checkout when one can be found — `--pin-source [PRODUCT=]`,
-`SHAPE_PIN_SOURCE_` in the environment, or a checkout sitting beside
-the assembly root and named for the pinned repository — else from `gh api`
-when `gh` can read it. Neither answering is a named SKIP, never a finding and
-never a failure: a project with no local checkout of its referent and no
-network is not thereby lying about its descent. A digest that recomputes
-differently, or a pin file the declaration names but that is not there, is
-reported as drift like every other pin in this file.
+Each `neutral_product_pins:` entry's `contracts/-pin.yaml`
+has its `commit`, `revision_kind` and `sorted-ls-tree-r-v1` digest recomputed as
+`scaffold-project.py --pin` computed them: OFFLINE from a local checkout when
+one is found — `--pin-source [PRODUCT=]`, `SHAPE_PIN_SOURCE_` in
+the environment, or a checkout beside the assembly root named for the pinned
+repository — else by `gh api`, with the operator's `gh` credentials, when `gh`
+can read it. Finding neither is a named SKIP, exit 0: no checkout and no network
+does not make a descent a lie. A checkout that cannot answer is the finding
+`neutral-pin-source-unreadable`. `make bootstrap`, `bump-leg.py` and
+`update-shape.py apply` run this validator in the operator's environment, so
+each makes that call too; none takes `--pin-source`.
### A neutral product may elect the shape and be its own assembly root
@@ -1025,12 +1025,12 @@ the rest is the project's own, which an upstream fix must never overwrite.
## Keeping a project's shape current
-The copies are the trade: a project runs its own gate offline, and an upstream
-FIX to a copied file reaches it never. On 2026-09-03 that bill came due — one
-change to the assembly-root `validate.yml`, and both projects carrying the
-shape were updated BY HAND: re-copy, recompute the file's `sha256` row, move
-`commit` and `digests.tree_sha256`, mirror both into `project.yaml`, re-run the
-validators. `update-shape.py` is that, as one command:
+The copies are the trade: a project runs its own gate, which never consults this
+standard (its validators' one possible network read is `validate-pins.py`'s `gh
+api` recheck of a neutral product's pin, above), and an upstream FIX to a copied
+file reaches it never. On 2026-09-03 that bill came due — one change to the
+assembly-root `validate.yml`, and both projects with the shape were updated BY
+HAND: re-copy, re-pin, re-validate. `update-shape.py` is that, as one command:
```sh
./update-shape.py check --root ../MedxEHR # writes nothing
@@ -1092,9 +1092,9 @@ by every `check` until it is taken or a file arrives at that path.
`owner/repo` it bare-clones itself; it defaults to the repository the pin
names. `make bootstrap` prints one line when the upstream has moved past the
pin AND this machine can already tell offline — `SHAPE_UPSTREAM_PATH` points at
-a clone, or a `.shape-upstream-tip` file carries a commit. It adds no network
-call: a bootstrap that paused to ask GitHub a question would have made the
-shape a dependency again.
+a clone, or a `.shape-upstream-tip` file carries a commit. That line adds no
+network call: a bootstrap that paused to ask GitHub a question would have
+made the shape a dependency again. (Its pins gate may ask `gh api`; see above.)
### Is this repository compliant? `openRepoShape --doctor`
diff --git a/docs/cli.md b/docs/cli.md
index 79cdbec8..cf6b5e7d 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -620,7 +620,9 @@ options:
writes; repeatable, kept in the order given
--local-remote-dir LOCAL_REMOTE_DIR
resolve the leg's remote to a bare repository here
- instead of its origin (the TEST path; no network)
+ instead of its origin (the TEST path; the leg needs no
+ network, but the project's own validate-pins.py may
+ still ask `gh api`)
--dry-run print what would move and change nothing
```
diff --git a/docs/handbook.html b/docs/handbook.html
index 9cf97a32..f3b51681 100644
--- a/docs/handbook.html
+++ b/docs/handbook.html
@@ -1110,6 +1110,20 @@ The three legs, and naming
project’s own project.yaml, so classification stays OFFLINE and never asks
GitHub whether open<Product> exists. The referent may also be reached
through a RECORDED chain of pins.
+ validate-pins.py rechecks the referent too, not only the declaration. Each
+ neutral_product_pins: entry’s
+ contracts/<product lowercased>-pin.yaml has its commit,
+ revision_kind and sorted-ls-tree-r-v1 digest recomputed as
+ scaffold-project.py --pin computed them: OFFLINE from a local checkout when one
+ is found — --pin-source [PRODUCT=]<path>,
+ SHAPE_PIN_SOURCE_<PRODUCT> in the environment, or a checkout beside the
+ assembly root named for the pinned repository — else by gh api, with the
+ operator’s gh credentials, when gh can read it. Finding
+ neither is a named SKIP, exit 0: no checkout and no network does not make a descent a lie. A
+ checkout that cannot answer is the finding neutral-pin-source-unreadable.
+ make bootstrap, bump-leg.py and update-shape.py apply
+ run this validator in the operator’s environment, so each makes that call too; none
+ takes --pin-source.
A NEUTRAL product may elect the shape and be its own assembly root as well:
openDox mounting openDox-spec and openDox-code is a
fact about LAYOUT and not a claim about neutrality, because electing confers nothing.
@@ -1365,9 +1379,11 @@
Bootstrap is COPIED into the project, not fetched
Reference
Keeping a project’s shape current
- The copies are the trade: a project runs its own gate offline, and an upstream FIX to a
- copied file reaches it never. update-shape.py is the re-sync, as one
- command.
+ The copies are the trade: a project runs its own gate, which never consults this standard
+ (its validators’ one possible network read is validate-pins.py’s
+ gh api recheck of a neutral product’s pin,
+ described with the legs), and an upstream FIX to a copied file reaches it
+ never. update-shape.py is the re-sync, as one command.
./update-shape.py check --root ../MedxEHR # writes nothing
./update-shape.py apply --root ../MedxEHR --at <commit> --yes \
--branch shape/update-<sha> # then open a pull request
@@ -1402,8 +1418,9 @@ Keeping a project’s shape current
bare-clones itself; it defaults to the repository the pin names. make bootstrap
prints one line when the upstream has moved past the pin AND this machine can already tell
offline — SHAPE_UPSTREAM_PATH points at a clone, or a
- .shape-upstream-tip file carries a commit. It adds no network call: a bootstrap
- that paused to ask GitHub a question would have made the shape a dependency again.
+ .shape-upstream-tip file carries a commit. That line adds no network call: a
+ bootstrap that paused to ask GitHub a question would have made the shape a dependency again.
+ (Its pins gate may ask gh api; see the legs.)
Full text — keeping the shape current
@@ -1802,9 +1819,12 @@ For AI assistants
or the leg’s pin that this user cannot open, by name, added (#383); what
setup.sh --help does from a download, which clones nothing, and which commands
list every flag, added (#341); bump-leg.py --dry-run running on the tracking
- branch and naming the branch the real run needs, added (#477) — a page cannot name
- the commit that contains it, and the blob below is the fact a test checks.
- README.md blob 3a4f26ade02d2086157f1bd30c533444101710f8. Every command, flag, exit code
+ branch and naming the branch the real run needs, added (#477); the one network
+ read a project’s own validators can make, the gh api recheck of a neutral
+ product’s pin, said where this page and the README had said “offline”,
+ added (#411) — a page cannot name the commit that contains it, and the blob below is
+ the fact a test checks.
+ README.md blob 5e311f176503d43a188d9974c7988b396dd0e0e1. Every command, flag, exit code
and file name on this page is quoted from that README and from AGENTS.md.
diff --git a/scripts/bump-leg.py b/scripts/bump-leg.py
index 4d10ed55..101f7dac 100755
--- a/scripts/bump-leg.py
+++ b/scripts/bump-leg.py
@@ -120,8 +120,14 @@
MANIFEST = "project.yaml"
#: The root's OWN validators, run after the rewrite and before the commit.
-#: Copies, not this checkout's: a project runs its own gate offline, and the
-#: only opinion that matters here is the one its pull request will report.
+#: Copies, not this checkout's: a project runs its own gate, and the only
+#: opinion that matters here is the one its pull request will report.
+#: NOT NETWORK-FREE, and the child gets this process's environment (#411): for
+#: a project declaring `neutral_product_pins:` whose product has no local
+#: checkout (a sibling of the root or `SHAPE_PIN_SOURCE_`; this tool
+#: takes no `--pin-source`), `validate-pins.py` asks `gh api` for the pinned
+#: tree with the operator's `gh` credentials. Without `gh`, a login or a
+#: network that is its `skip` line, exit 0, and the bump goes on.
VALIDATORS = ("scripts/validate-pins.py", "scripts/validate-manifest.py")
#: A local-path remote is a `file://` fetch, which git has refused for
@@ -1531,8 +1537,9 @@ def build_parser() -> argparse.ArgumentParser:
"order given")
parser.add_argument("--local-remote-dir", type=Path, default=None,
help="resolve the leg's remote to a bare repository "
- "here instead of its origin (the TEST path; no "
- "network)")
+ "here instead of its origin (the TEST path; the "
+ "leg needs no network, but the project's own "
+ "validate-pins.py may still ask `gh api`)")
parser.add_argument("--dry-run", action="store_true",
help="print what would move and change nothing")
return parser
diff --git a/tests/test_update_shape_pr_base.py b/tests/test_update_shape_pr_base.py
index 90d309d2..c895b4b1 100644
--- a/tests/test_update_shape_pr_base.py
+++ b/tests/test_update_shape_pr_base.py
@@ -16,7 +16,7 @@
manifest records NO tracking branch at all — which `validate-manifest.py`
accepts and which every other tool (`bootstrap.py`, `bump-leg.py`,
`family.py`, `commit_on_branch`'s own refusal) reads as `main`. The remote's
-HEAD is never consulted for that last case: `apply` is otherwise offline.
+HEAD is never consulted for that last case: the base comes from the manifest.
THE UPSTREAM IS A CLONE OF THIS REPOSITORY, the project is scaffolded out of
it into bare repositories in a temporary directory, and `gh` is stubbed at the
diff --git a/update-shape.py b/update-shape.py
index a1ac2652..8643b3f4 100755
--- a/update-shape.py
+++ b/update-shape.py
@@ -331,8 +331,14 @@ def open_upstream(spec: str) -> Upstream:
A PATH IS USED IN PLACE AND NOTHING IS FETCHED. That is deliberate: it is
what lets the tests run with no network, and it is also the honest answer
for an organisation whose fork is the upstream. `owner/repo` is bare-cloned
- into a temporary directory, which is the one place this tool touches a
- network, and it refuses loudly rather than degrading to a guess.
+ into a temporary directory, which is the one place this tool fetches the
+ shape, and it refuses loudly rather than degrading to a guess. It is not
+ the only place `apply` can touch a network (#411): the project's own
+ `validate-pins.py` runs in this process's environment, so for a project
+ declaring `neutral_product_pins:` whose product has no local checkout it
+ asks `gh api` for the pinned tree with the operator's `gh` credentials
+ (`skip` line and exit 0 without `gh`, a login or a network), and `--push`
+ and `--pr` push the branch and open its pull request.
"""
candidate = Path(spec).expanduser()
if candidate.exists():
@@ -364,9 +370,9 @@ def open_upstream(spec: str) -> Upstream:
"update-upstream-unresolvable",
f"could not clone {url} (git exit {proc.returncode}): "
f"{(proc.stderr or '').strip()}",
- "Remediation: this is the one step that needs a network. Clone "
- "the shape once by hand and pass `--upstream ` instead; "
- "everything after it is offline.")
+ "Remediation: this is the one step that fetches the shape from a "
+ "network. Clone the shape once by hand and pass `--upstream "
+ "` instead; nothing after it fetches the shape again.")
return Upstream(temporary / "shape.git", spec, temporary=temporary)
@@ -1858,9 +1864,8 @@ def tracking_branch_of(manifest: dict | None) -> str:
`main` where the manifest records none: the default `bootstrap.py`,
`bump-leg.py` and `family.py` read the field with. The remote's own HEAD
- is never asked: `apply` is otherwise offline, and a base taken from the
- network could disagree with the one the `--branch` refusal and `--pr`
- read from the manifest.
+ is never asked: a base taken from the network could disagree with the
+ one the `--branch` refusal and `--pr` read from the manifest.
"""
return str((manifest or {}).get("tracking_branch") or "main")