git-workflow — pin-intent-and-currency
Detail page of the git-workflow recipe card.
The pin contract — intent, the two structural rules, and the states
Section titled “The pin contract — intent, the two structural rules, and the states”The umbrella records one commit per submodule (a gitlink) and checks it out
DETACHED and clean. What that recorded value is ALLOWED to be, and whether it is
the value the owning repo actually SERVES, is the pin contract. It lives in
ONE verb — verb: git-submodules, owned by opencharly/plugin-task — whose
mode is status | bump | verify | currency. The first three are the
maintenance modes (the task table above; “task sync and the commit gate” below), and
bump is the ADVANCE — it moves EVERY pin the umbrella declares, and it is the only
mode of the verb that writes.
currency is a first-class mode of the SAME verb, not a second
implementation, and it is the mode that answers “is the umbrella current?”.
The mode is the CONTRACT, and it IS in a released binary. Measured 2026-10-23
on the built binary (2026.295.1709), the umbrella’s own task surface answers the
question: charly task pins --param MODE=currency names each pin’s state and
the producer-first leg order. So a reader who needs the question answered does
NOT MEASURE it by hand: a hand-built <path>: <recorded gitlink> vs <remote default-branch HEAD> table is a second implementation of a verb that already
answers it, and it drifts. Nor should the guidance carry the verb’s COUNTS:
they are a LIVE reading of the org’s remotes, not a constant — the same binary
can report a different number an hour later than it did before, because the
remotes advanced in between. The counts belong to the verb’s own output, where
they are stamped. Everything below states the shapes and the states as RULES
rather than as a release note for a version.
currency reads NO checkout state, and that is deliberate rather than
incidental. It compares the only two objects that can answer the question — the
recorded gitlink and the REMOTE — so the index-vs-checkout seam
(opencharly/opencharly#461) cannot arise in it by construction. Ancestry is the
ONE read that needs local objects, and its ABSENCE is REPORTED (differs (no checkout to judge ancestry)) rather than folded into a verdict.
A pin declares its INTENT
Section titled “A pin declares its INTENT”Every pin declares WHY it holds the value it does, in one of exactly two shapes, and they are not interchangeable:
| intent | kind | what “current” means |
|---|---|---|
upstream:<ref> |
LEAF | the owning repo’s OWN ref, read from the REMOTE. No local state enters the answer, so a leaf cannot inherit another repo’s staleness. |
derived-from:<path> |
EDGE | the value is <path>’s recorded gitlink IN THIS REPO — so it is current exactly when that source is. When the source is not current the pin is blocked-by, and the producer leg is NAMED instead of discovered an hour later. |
An entry in pin_map IS an edge — derived-from:<pinned_from> — and needs no
intent: entry of its own. A path with neither an intent: entry nor a branch=
in .gitmodules is unpinned: reported as such, never silently treated as a
leaf. Conversely a path with no intent: but WITH a branch= does have an
intent — upstream:<branch> (implicit: .gitmodules branch=) — because bump
rolls every path outside pin_map to its own default-branch HEAD, so that IS
what such a pin means; it was simply never written down. The output counts
implicit leaves separately, so no intent stays silent even when every pin is
current.
The two structural rules
Section titled “The two structural rules”- An edge may not point at another edge — depth ≤ 2. The graph is acyclic BY
CONSTRUCTION, not by a validation that can be skipped, and the check covers
EVERY edge source —
pinned_fromAND each explicitderived-from:<path>. A source that is itself an edge (apin_mapentry, or a path that declares aderived-from:intent) is refused BY NAME (graph depth > 1: … an edge may not point at an edge). Checking onlypinned_fromleaves the explicit shape unenforced while the schema states the rule generally — which is the one shape a second edge is most likely to take. An arm per shape is not the same control as an assertion that a shape cannot bypass the rule. THIS rule’s control is its shape — one classified graph, walked over every edge, into which a depth-3 chain cannot be expressed — and only then is an arm per shape worth writing. The live bed is the other half, and it is the half that failed: it drovepin_mapalone, i.e. the shape that was already guarded, and reached its PASS line without ever exercising the shape the contract had just introduced. - Every leaf must be remote-verifiable. A leaf is judged against the remote
ref, so a leaf no namespace serves is structural (
ref-not-served), and an edge whose declared source records no gitlink is refused rather than inherited from nothing.
Those two rules are the whole difference from the consistency contract this
replaces. A contract that is satisfiable at BOTH ends while both ends are stale
is not a currency contract, it is a mirror — which is exactly how policy B held
while the distro-* pins were behind their own main.
The states, and what each obliges
Section titled “The states, and what each obliges”| state | fails? | what it means, and what it obliges |
|---|---|---|
cur |
no | the recorded gitlink IS what the intent names. Obliges nothing. |
behind |
no | a LEAF whose recorded commit is an ANCESTOR of the served ref. The advance is available NOW: it is the next leg of the sequence, and the remedy is charly task sync + a PR. |
blocked-by <source> (<state>) / blocked-by <source>:<twin> (<state>) |
no, when it names a leg | an EDGE that cannot be advanced from HERE because its producer is not current: an explicit derived-from:<path> inherits <source>’s own state, while a pin_map edge carries the state of the source’s OWN pin at the twin — one hop further than the gitlink, which is the hop the old contract never took. Either way it NAMES the producer: make that producer current first, and this pin follows. |
not-on-the-served-line |
no | neither commit is an ancestor of the other. This is NOT a divergence and must not be read as one: charly’s history is squash-merged, so a pin taken before a merge is unreachable from the served ref while the tree is perfectly healthy — and a hand-pin to an unrelated commit yields the SAME two SHAs. The objects cannot tell the two apart, so the honest report is the class plus the ONE remedy right for both: advance the pin (charly task sync). |
differs (no checkout to judge ancestry) |
no | the SHAs differ and the ancestry read could not be made, because the checkout (or, for a twin, the nested checkout) is not there. Reported, never folded into a verdict — and a fresh session worktree is full of them, so this state is normal, not alarming. |
unpinned |
YES | no intent and no branch= — the pin cannot be judged at all. Obliges declaring one. |
no-source-pin |
YES | an EDGE whose declared source names no path, or records no gitlink in this repo’s index — there is nothing to inherit. |
pin-mismatch |
YES | an EDGE whose recorded gitlink disagrees with its source’s. The two sides are made to agree by the sanctioned verb (or by the producing repo’s PR), never by a hand-pin. |
unmerged |
YES | the recorded commit is AHEAD of the served ref — the pin names a commit the repo does not serve. A PR branch was pinned; re-point it at a MERGED ref. |
ref-not-served |
YES | a LEAF for which no branch or tag of that name is served by the submodule’s URL — the intent ITSELF is wrong. |
no-gitlink / no-url |
YES | the objects the contract rests on are missing from the index / .gitmodules. |
blocked-by <source> (not a declared pin) / blocked-by <source>:<twin> (no ref declared) |
YES | the edge names a producer that is NOT a declared pin, or a twin its source declares no branch=/url= for — so there is no leg to name and the pin is UNJUDGEABLE. The remedy is upstream: declare the producer, or give this pin its own upstream: intent. |
Why behind and blocked-by PASS — and why the output leads with them
Section titled “Why behind and blocked-by PASS — and why the output leads with them”The FAIL set is STRUCTURAL only, and “structural” has a precise meaning:
what fails is a pin the workflow cannot JUDGE, or two sides that DISAGREE.
Everything it CAN judge and finds merely not yet advanced PASSES and is
reported — so behind, not-on-the-served-line, and a blocked-by that names
a real producer leg are not failures: they are legs of a cross-repo sequence whose
next step is ANOTHER REPO’S PR. A blocked-by that names NO leg is the
exception and IS structural: there is no producer to wait for, so the only thing
the reader can do is declare one.
Failing on them would make the gate permanently red on a healthy tree — and a gate that is always red trains every reader to ignore it, which is the mirror problem inverted. So instead of failing, the Message LEADS with the number of pins that are NOT current, then the intent counts, then every non-current pin with its path, state, intent and both SHAs:
currency: <N> of <M> pin(s) NOT current [<tally, e.g. "1 blocked-by <src>:<twin> (<state>)", "419 cur">]; intents: <L> upstream leaf/leaves (<I> implicit), <D> derived-from edge(s); NOT CURRENT: <path>: <state>; intent <intent>; recorded <sha> [; served <sha>] [(<which>)]; …; producer-first legs: <legs>A green run can therefore never read as “everything is fine”. The count is
the first thing in the string, and 0 of <M> is the only shape that means
current. Measured against the REAL umbrella at 96195c1, with a
charly built from the implementation at 745f6d7: 14 of 429 pins not current
as of 2026-10-15 — 415 cur, 2 behind, 4 not-on-the-served-line, 2 differs (no checkout to judge ancestry), and the five unreachable distro-* pins each reporting
blocked-by charly:box/<ns> (no ref declared) — 5 of them STRUCTURAL. The intent
counts reconcile exactly — 423 implicit upstream: leaves + 6 derived-from:
edges = 429 — and they are IDENTICAL to the counts taken before the round that
changed the depth rule, which is how a reader can see that the change moved the
RULE and not the classification.
The producer-first leg order
Section titled “The producer-first leg order”The Message ends with producer-first legs: — the ORDER in which the sequence can
actually advance:
- the leaves that can advance now, each
behindleaf as<path> (<recorded> -> <served>); - then the edges those leaves unblock, each
blocked-byedge, verbatim.
The legs are joined with ->, and the two degenerate readings are DISTINCT:
none — every pin is at the ref its intent names (nothing is stale) versus
none reachable from this repo (this repo HAS stale pins, but every one of them
is blocked by a producer it cannot reach). Read the legs as the work list, not
as decoration: a blocked-by names the repo whose PR must land first, which is
the same producer-first rule the umbrella’s cross-repo landing order states —
producer PR → merge → tag → consumer pin bump → umbrella gitlink bump.
The intents belong in THIS repo’s manifest
Section titled “The intents belong in THIS repo’s manifest”The umbrella declares its own intents in its charly.yml, next to the pins they
describe, and the six distro-* edges are the ONE pin_map in the pins task —
never a second copy. Two consequences worth knowing before editing either:
- A
distro-*pin cannot be advanced from the umbrella when charly’s ownbox/*gitlink is behind. The verb’s source of truth for the edge IS that gitlink, andcharlydeclares intent forbox/archonly — so its other five nested pins are not judgeable at all and reportblocked-by charly:box/<ns> (no ref declared). The remedy is one hop upstream: declare the intent inopencharly/charly(its ownbox/*), and the umbrella’s pin then follows throughcharly task sync. Never hand-pin adistro-*. - The verb is domain-neutral by rule. It knows paths, gitlinks and refs, and
nothing about what a submodule CONTAINS. A fact about a repository’s own
composition rather than about a pin — a harness-config binding, a marketplace
corpus family, a generated file — is that repository’s own gate’s to own, and
asking the pin verb to see it would break its reusability for every other
repository (the same reason its schema bakes in no org name and no distro
list). See the harness-config half of an advance in
references/umbrella-mechanics.md.

